diff --git a/pyproject.toml b/pyproject.toml index 6f7056a0..f1a836ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "tqdm>=4.62", "matplotlib>=3.3", "scikit-learn>=1.0", - "transformers>=4.25", + "transformers>=4.46", "huggingface-hub>=0.16", "datasets>=2.0", "peft>=0.4", diff --git a/src/opentslm/model/llm/OpenTSLMFlamingo.py b/src/opentslm/model/llm/OpenTSLMFlamingo.py index b0f5676b..1fb57671 100644 --- a/src/opentslm/model/llm/OpenTSLMFlamingo.py +++ b/src/opentslm/model/llm/OpenTSLMFlamingo.py @@ -10,10 +10,11 @@ ) from open_flamingo.src.flamingo_lm import FlamingoLMMixin from open_flamingo.src.utils import extend_instance +import random import torch import torch._dynamo -from typing import List, Dict, Tuple -from transformers import AutoTokenizer, AutoModelForCausalLM +from typing import Dict, Iterator, List, Tuple +from transformers import AutoTokenizer, AutoModelForCausalLM, TextIteratorStreamer from opentslm.model_config import ENCODER_OUTPUT_DIM from opentslm.model.llm.TimeSeriesLLM import TimeSeriesLLM @@ -108,12 +109,16 @@ def _infer_decoder_layers_attr_name(model): return __KNOWN_DECODER_LAYERS_ATTR_NAMES[k] raise ValueError( - f"We require the attribute name for the nn.ModuleList in the decoder storing the transformer block layers. Please supply this string manually." + "We require the attribute name for the nn.ModuleList in the decoder storing the transformer block layers. Please supply this string manually." ) decoder_layers_attr_name = _infer_decoder_layers_attr_name(lang_encoder) lang_encoder.set_decoder_layers_attr_name(decoder_layers_attr_name) - lang_encoder.resize_token_embeddings(len(text_tokenizer)) + # Avoid Transformers' mean-based resize path, which can trip on meta tensors + # during low-memory model initialization in production worker environments. + lang_encoder.resize_token_embeddings( + len(text_tokenizer), mean_resizing=False + ) # Fix compatibility for Gemma3Config which has hidden_size in text_config if hasattr(lang_encoder.config, "text_config") and hasattr( @@ -152,6 +157,39 @@ def _infer_decoder_layers_attr_name(model): self.llm = model self.text_tokenizer = text_tokenizer + def _build_input_embeddings(self, input_ids: torch.Tensor) -> torch.Tensor: + return self.model.lang_encoder.get_input_embeddings()(input_ids) + + def _forward_with_embeddings( + self, + images: torch.Tensor, + input_ids: torch.Tensor, + attention_mask: torch.Tensor, + labels: torch.Tensor | None = None, + ): + inputs_embeds = self._build_input_embeddings(input_ids) + try: + self.model._encode_vision_x(vision_x=images) + self._condition_media_locations(input_ids) + return super(FlamingoLMMixin, self.model.lang_encoder).forward( + inputs_embeds=inputs_embeds, + attention_mask=attention_mask, + labels=labels, + ) + finally: + self.model.lang_encoder.clear_conditioned_layers() + + def _condition_media_locations(self, input_ids: torch.Tensor): + media_locations = input_ids == self.model.media_token_id + attend_previous = ( + (random.random() < 0.5) + if self.model.lang_encoder.use_media_placement_augmentation + else False + ) + for layer in self.model.lang_encoder._get_decoder_layers(): + layer.condition_media_locations(media_locations) + layer.condition_attend_previous(attend_previous) + def pad_and_apply_batch( self, batch: List[Dict[str, any]], include_labels: bool ) -> Tuple[torch.Tensor, torch.Tensor]: @@ -255,16 +293,20 @@ def generate( input_ids, images, attention_mask, _ = self.pad_and_apply_batch( batch, include_labels=True ) - - gen_ids = self.llm.generate( - vision_x=images, - lang_x=input_ids, - attention_mask=attention_mask, - max_new_tokens=max_new_tokens, - eos_token_id=self.text_tokenizer.eos_token_id, - pad_token_id=self.text_tokenizer.pad_token_id, - **generate_kwargs, - ) + inputs_embeds = self._build_input_embeddings(input_ids) + try: + self.model._encode_vision_x(vision_x=images) + self._condition_media_locations(input_ids) + gen_ids = self.model.lang_encoder.generate( + inputs_embeds=inputs_embeds, + attention_mask=attention_mask, + max_new_tokens=max_new_tokens, + eos_token_id=self.text_tokenizer.eos_token_id, + pad_token_id=self.text_tokenizer.pad_token_id, + **generate_kwargs, + ) + finally: + self.model.lang_encoder.clear_conditioned_layers() # Remove input ids from generation answer_only_ids = gen_ids[:, input_ids.shape[1] :] @@ -276,6 +318,46 @@ def generate( # Restore original compilation setting torch._dynamo.config.disable = original_disable + def stream_generate( + self, batch: List[Dict[str, any]], max_new_tokens: int = 50, **generate_kwargs + ) -> Iterator[str]: + self._validate_streaming_batch(batch) + + original_disable = torch._dynamo.config.disable + torch._dynamo.config.disable = True + try: + with torch.inference_mode(): + input_ids, images, attention_mask, _ = self.pad_and_apply_batch( + batch, include_labels=True + ) + inputs_embeds = self._build_input_embeddings(input_ids) + streamer = TextIteratorStreamer( + self.text_tokenizer, + skip_prompt=True, + timeout=generate_kwargs.pop("stream_timeout", None), + ) + + def run_generation() -> None: + with torch.inference_mode(): + try: + self.model._encode_vision_x(vision_x=images) + self._condition_media_locations(input_ids) + self.model.lang_encoder.generate( + inputs_embeds=inputs_embeds, + attention_mask=attention_mask, + max_new_tokens=max_new_tokens, + eos_token_id=self.text_tokenizer.eos_token_id, + pad_token_id=self.text_tokenizer.pad_token_id, + streamer=streamer, + **generate_kwargs, + ) + finally: + self.model.lang_encoder.clear_conditioned_layers() + + yield from self._iterate_streamer(streamer, run_generation) + finally: + torch._dynamo.config.disable = original_disable + def compute_loss(self, batch: List[Dict[str, any]]) -> torch.Tensor: """ batch: same format as generate() @@ -285,9 +367,9 @@ def compute_loss(self, batch: List[Dict[str, any]]) -> torch.Tensor: batch, include_labels=False ) - output = self.model( - vision_x=images, - lang_x=input_ids, + output = self._forward_with_embeddings( + images=images, + input_ids=input_ids, attention_mask=attention_mask, labels=labels, ) @@ -333,13 +415,13 @@ def load_from_file(self, path: str = "best_model.pt"): # Load state dict with strict=False to handle missing/unexpected keys missing_keys, unexpected_keys = self.load_state_dict(model_state, strict=False) if missing_keys: - print(f"⚠️ Warning: Missing keys when loading checkpoint:") + print("⚠️ Warning: Missing keys when loading checkpoint:") for key in missing_keys[:10]: print(f" - {key}") if len(missing_keys) > 10: print(f" ... and {len(missing_keys) - 10} more keys") if unexpected_keys: - print(f"⚠️ Warning: Unexpected keys when loading checkpoint:") + print("⚠️ Warning: Unexpected keys when loading checkpoint:") for key in unexpected_keys[:10]: print(f" - {key}") if len(unexpected_keys) > 10: @@ -368,3 +450,19 @@ def eval_prompt( finally: # Restore original compilation setting torch._dynamo.config.disable = original_disable + + def stream_prompt( + self, + prompt: FullPrompt, + max_new_tokens: int = 1000, + normalize: bool = False, + **generate_kwargs, + ) -> Iterator[str]: + batch = [prompt.to_dict()] + self.eval() + batch = extend_time_series_to_match_patch_size_and_aggregate( + batch, normalize=normalize + ) + yield from self.stream_generate( + batch, max_new_tokens=max_new_tokens, **generate_kwargs + ) diff --git a/src/opentslm/model/llm/OpenTSLMSP.py b/src/opentslm/model/llm/OpenTSLMSP.py index 4e7735d8..e05deb94 100644 --- a/src/opentslm/model/llm/OpenTSLMSP.py +++ b/src/opentslm/model/llm/OpenTSLMSP.py @@ -4,9 +4,8 @@ # SPDX-License-Identifier: MIT import torch -import torch.nn as nn -from typing import List, Dict, Tuple, Optional -from transformers import AutoTokenizer, AutoModelForCausalLM +from typing import Any, Dict, Iterator, List, Optional, Tuple +from transformers import AutoTokenizer, AutoModelForCausalLM, TextIteratorStreamer from torch.nn.utils.rnn import pad_sequence try: @@ -47,7 +46,11 @@ def __init__( device_map={"": device}, attn_implementation="eager", ) - self.llm.resize_token_embeddings(len(self.tokenizer)) + # Avoid Transformers' mean-based resize path, which can trip on meta tensors + # during low-memory model initialization in production worker environments. + self.llm.resize_token_embeddings( + len(self.tokenizer), mean_resizing=False + ) # 3) encoder + projector (now internal) self.encoder = TransformerCNNEncoder().to(device) @@ -133,7 +136,7 @@ def enable_lora( p.numel() for p in self.llm.parameters() if p.requires_grad ) total_params = sum(p.numel() for p in self.llm.parameters()) - print(f"✅ LoRA enabled:") + print("✅ LoRA enabled:") print(f" LoRA parameters: {lora_params:,}") print(f" Total trainable parameters: {trainable_params:,}") print(f" Total parameters: {total_params:,}") @@ -327,6 +330,29 @@ def generate( ) return self.tokenizer.batch_decode(gen_ids, skip_special_tokens=True) + def stream_generate( + self, batch: List[Dict[str, Any]], max_new_tokens: int = 50, **generate_kwargs + ) -> Iterator[str]: + self._validate_streaming_batch(batch) + + inputs_embeds, attention_mask = self.pad_and_apply_batch(batch) + streamer = TextIteratorStreamer( + self.tokenizer, + skip_prompt=True, + timeout=generate_kwargs.pop("stream_timeout", None), + ) + + def run_generation() -> None: + self.llm.generate( + inputs_embeds=inputs_embeds, + attention_mask=attention_mask, + max_new_tokens=max_new_tokens, + streamer=streamer, + **generate_kwargs, + ) + + yield from self._iterate_streamer(streamer, run_generation) + def compute_loss(self, batch: List[Dict[str, any]]) -> torch.Tensor: """ batch: same format as generate() @@ -415,13 +441,6 @@ def load_lora_state_from_checkpoint( loaded_count = 0 missing_keys = [] - # Track which LoRA parameters we expect to find - expected_lora_params = { - name - for name, param in self.llm.named_parameters() - if param.requires_grad and "lora_" in name - } - for name, param in self.llm.named_parameters(): if name in lora_state and param.requires_grad and "lora_" in name: param.data.copy_(lora_state[name]) @@ -504,3 +523,19 @@ def eval_prompt( ) output = self.generate(batch, max_new_tokens=max_new_tokens) return output[0] + + def stream_prompt( + self, + prompt: FullPrompt, + max_new_tokens: int = 30000, + normalize: bool = False, + **generate_kwargs, + ) -> Iterator[str]: + batch = [prompt.to_dict()] + self.eval() + batch = extend_time_series_to_match_patch_size_and_aggregate( + batch, normalize=normalize + ) + yield from self.stream_generate( + batch, max_new_tokens=max_new_tokens, **generate_kwargs + ) diff --git a/src/opentslm/model/llm/TimeSeriesLLM.py b/src/opentslm/model/llm/TimeSeriesLLM.py index b3a568d5..1674fb40 100644 --- a/src/opentslm/model/llm/TimeSeriesLLM.py +++ b/src/opentslm/model/llm/TimeSeriesLLM.py @@ -1,4 +1,5 @@ -from typing import List, Dict, Any +from threading import Thread +from typing import Any, Callable, Dict, Iterator, List # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md) # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project. @@ -25,6 +26,13 @@ def generate( raise NotImplementedError("Generate method should be implemented by the subclass") + def stream_generate( + self, batch: List[Dict[str, Any]], max_new_tokens: int = 50, **generate_kwargs + ) -> Iterator[str]: + raise NotImplementedError( + "stream_generate method should be implemented by the subclass" + ) + def compute_loss(self, batch: List[Dict[str, Any]]) -> torch.Tensor: """ batch: same format as generate() @@ -36,4 +44,45 @@ def get_eos_token(self) -> str: raise NotImplementedError("Get eos token method should be implemented by the subclass") def eval_prompt(self, prompt: FullPrompt) -> str: - raise NotImplementedError("Eval prompt method should be implemented by the subclass") \ No newline at end of file + raise NotImplementedError("Eval prompt method should be implemented by the subclass") + + def stream_prompt( + self, prompt: FullPrompt, max_new_tokens: int = 1000, normalize: bool = False, **generate_kwargs + ) -> Iterator[str]: + raise NotImplementedError( + "stream_prompt method should be implemented by the subclass" + ) + + @staticmethod + def _validate_streaming_batch(batch: List[Dict[str, Any]]) -> None: + if len(batch) != 1: + raise ValueError( + "Streaming generation currently supports exactly one sample per batch." + ) + + @staticmethod + def _iterate_streamer(streamer: Any, generate_fn: Callable[[], None]) -> Iterator[str]: + error: BaseException | None = None + + def runner() -> None: + nonlocal error + try: + generate_fn() + except BaseException as exc: # pragma: no cover - re-raised in caller + error = exc + end = getattr(streamer, "end", None) + if callable(end): + end() + + thread = Thread(target=runner, daemon=True) + thread.start() + + try: + for text in streamer: + if text: + yield text + finally: + thread.join() + + if error is not None: + raise error diff --git a/test/test_stream_inference.py b/test/test_stream_inference.py new file mode 100644 index 00000000..9e01b7e5 --- /dev/null +++ b/test/test_stream_inference.py @@ -0,0 +1,346 @@ +# SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md) +# SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project. +# +# SPDX-License-Identifier: MIT + +from queue import Queue +from types import SimpleNamespace + +import pytest +import torch + +import opentslm.model.llm.OpenTSLMFlamingo as flamingo_module +import opentslm.model.llm.OpenTSLMSP as sp_module +from opentslm.model.llm.OpenTSLMFlamingo import OpenTSLMFlamingo +from opentslm.model.llm.OpenTSLMSP import OpenTSLMSP + + +class FakeStreamer: + instances = [] + + def __init__(self, tokenizer, skip_prompt=False, timeout=None, **decode_kwargs): + self.tokenizer = tokenizer + self.skip_prompt = skip_prompt + self.timeout = timeout + self.decode_kwargs = decode_kwargs + self._queue = Queue() + FakeStreamer.instances.append(self) + + def put(self, text): + self._queue.put(text) + + def end(self): + self._queue.put(None) + + def __iter__(self): + while True: + item = self._queue.get() + if item is None: + break + yield item + + +class FakePrompt: + def __init__(self, payload): + self.payload = payload + + def to_dict(self): + return self.payload + + +def build_sp_model(): + model = OpenTSLMSP.__new__(OpenTSLMSP) + torch.nn.Module.__init__(model) + model.device = "cpu" + model.tokenizer = object() + model.pad_and_apply_batch = lambda batch: ( + torch.ones((1, 2, 3)), + torch.ones((1, 2), dtype=torch.long), + ) + return model + + +def build_flamingo_model(): + model = OpenTSLMFlamingo.__new__(OpenTSLMFlamingo) + torch.nn.Module.__init__(model) + model.device = "cpu" + model.text_tokenizer = SimpleNamespace(eos_token_id=7, pad_token_id=0) + model.pad_and_apply_batch = lambda batch, include_labels=True: ( + torch.tensor([[1, 2, 3]], dtype=torch.long), + torch.ones((1, 1, 1, 4), dtype=torch.float32), + torch.ones((1, 3), dtype=torch.long), + None, + ) + model._build_input_embeddings = lambda input_ids: torch.ones( + (1, input_ids.shape[1], 4), dtype=torch.float32 + ) + model._condition_media_locations = lambda input_ids: None + return model + + +def test_sp_stream_generate_yields_chunks_and_skips_prompt(monkeypatch): + FakeStreamer.instances.clear() + monkeypatch.setattr(sp_module, "TextIteratorStreamer", FakeStreamer) + + model = build_sp_model() + + class FakeLLM: + def generate(self, **kwargs): + streamer = kwargs["streamer"] + streamer.put("Hel") + streamer.put("") + streamer.put("lo") + streamer.end() + + model.llm = FakeLLM() + + chunks = list(model.stream_generate([{"sample": 1}], max_new_tokens=12)) + + assert chunks == ["Hel", "lo"] + assert "".join(chunks) == "Hello" + assert FakeStreamer.instances[0].skip_prompt is True + + +def test_sp_stream_generate_surfaces_generation_errors(monkeypatch): + monkeypatch.setattr(sp_module, "TextIteratorStreamer", FakeStreamer) + + model = build_sp_model() + + class FailingLLM: + def generate(self, **kwargs): + raise RuntimeError("generation failed") + + model.llm = FailingLLM() + + with pytest.raises(RuntimeError, match="generation failed"): + list(model.stream_generate([{"sample": 1}])) + + +def test_stream_generate_rejects_batched_requests(monkeypatch): + monkeypatch.setattr(sp_module, "TextIteratorStreamer", FakeStreamer) + monkeypatch.setattr(flamingo_module, "TextIteratorStreamer", FakeStreamer) + + sp_model = build_sp_model() + sp_model.llm = SimpleNamespace(generate=lambda **kwargs: None) + + flamingo_model = build_flamingo_model() + flamingo_model.model = SimpleNamespace( + _encode_vision_x=lambda vision_x: None, + lang_encoder=SimpleNamespace( + generate=lambda **kwargs: None, + clear_conditioned_layers=lambda: None, + ), + ) + + with pytest.raises(ValueError, match="exactly one sample"): + list(sp_model.stream_generate([{"a": 1}, {"b": 2}])) + + with pytest.raises(ValueError, match="exactly one sample"): + list(flamingo_model.stream_generate([{"a": 1}, {"b": 2}])) + + +def test_sp_stream_prompt_converts_prompt_and_forwards_kwargs(monkeypatch): + model = build_sp_model() + model.train() + + captured = {} + + def fake_extend(batch, normalize=False, patch_size=None): + captured["extend"] = { + "batch": batch, + "normalize": normalize, + "patch_size": patch_size, + } + return [{"prepared": True, **batch[0]}] + + def fake_stream_generate(batch, max_new_tokens=50, **generate_kwargs): + captured["stream_generate"] = { + "batch": batch, + "max_new_tokens": max_new_tokens, + "generate_kwargs": generate_kwargs, + } + yield "done" + + monkeypatch.setattr( + sp_module, + "extend_time_series_to_match_patch_size_and_aggregate", + fake_extend, + ) + model.stream_generate = fake_stream_generate + + chunks = list( + model.stream_prompt( + FakePrompt({"prompt": "value"}), + max_new_tokens=9, + normalize=True, + temperature=0.2, + ) + ) + + assert chunks == ["done"] + assert model.training is False + assert captured["extend"]["batch"] == [{"prompt": "value"}] + assert captured["extend"]["normalize"] is True + assert captured["stream_generate"]["batch"] == [{"prepared": True, "prompt": "value"}] + assert captured["stream_generate"]["max_new_tokens"] == 9 + assert captured["stream_generate"]["generate_kwargs"] == {"temperature": 0.2} + + +def test_flamingo_stream_generate_clears_conditioned_layers_on_success(monkeypatch): + FakeStreamer.instances.clear() + monkeypatch.setattr(flamingo_module, "TextIteratorStreamer", FakeStreamer) + + model = build_flamingo_model() + clear_calls = [] + + class FakeLangEncoder: + def generate(self, **kwargs): + streamer = kwargs["streamer"] + streamer.put("A") + streamer.put("B") + streamer.end() + + def clear_conditioned_layers(self): + clear_calls.append("cleared") + + model.model = SimpleNamespace( + _encode_vision_x=lambda vision_x: None, + lang_encoder=FakeLangEncoder(), + ) + + chunks = list(model.stream_generate([{"sample": 1}], max_new_tokens=5)) + + assert chunks == ["A", "B"] + assert clear_calls == ["cleared"] + assert FakeStreamer.instances[0].skip_prompt is True + + +def test_flamingo_stream_generate_clears_conditioned_layers_on_error(monkeypatch): + monkeypatch.setattr(flamingo_module, "TextIteratorStreamer", FakeStreamer) + + model = build_flamingo_model() + clear_calls = [] + + class FailingLangEncoder: + def generate(self, **kwargs): + raise RuntimeError("flamingo failure") + + def clear_conditioned_layers(self): + clear_calls.append("cleared") + + model.model = SimpleNamespace( + _encode_vision_x=lambda vision_x: None, + lang_encoder=FailingLangEncoder(), + ) + + with pytest.raises(RuntimeError, match="flamingo failure"): + list(model.stream_generate([{"sample": 1}])) + + assert clear_calls == ["cleared"] + + +def test_sp_init_disables_mean_resizing(monkeypatch): + resize_calls = [] + + class FakeTokenizer: + pad_token = None + eos_token = "" + + def __len__(self): + return 11 + + class FakeLLM: + config = SimpleNamespace(hidden_size=8) + + def resize_token_embeddings(self, size, **kwargs): + resize_calls.append((size, kwargs)) + + def parameters(self): + return [] + + monkeypatch.setattr( + sp_module.AutoTokenizer, + "from_pretrained", + lambda *args, **kwargs: FakeTokenizer(), + ) + monkeypatch.setattr( + sp_module.AutoModelForCausalLM, + "from_pretrained", + lambda *args, **kwargs: FakeLLM(), + ) + monkeypatch.setattr( + sp_module, + "TransformerCNNEncoder", + lambda: torch.nn.Identity(), + ) + monkeypatch.setattr( + sp_module, + "MLPProjector", + lambda *args, **kwargs: torch.nn.Identity(), + ) + + OpenTSLMSP(llm_id="dummy", device="cpu") + + assert resize_calls == [(11, {"mean_resizing": False})] + + +def test_flamingo_init_disables_mean_resizing(monkeypatch): + resize_calls = [] + + class FakeTokenizer: + pad_token = None + + def add_special_tokens(self, mapping): + if "pad_token" in mapping: + self.pad_token = mapping["pad_token"] + + def encode(self, value): + return [1] + + def __len__(self): + return 13 + + class GemmaForCausalLM: + config = SimpleNamespace(hidden_size=8) + + def resize_token_embeddings(self, size, **kwargs): + resize_calls.append((size, kwargs)) + + def set_decoder_layers_attr_name(self, value): + self.decoder_layers_attr_name = value + + class FakeFlamingoModel(torch.nn.Module): + def __init__(self, *args, **kwargs): + super().__init__() + self.perceiver = torch.nn.Linear(1, 1) + self.vision_encoder = torch.nn.Linear(1, 1) + self.lang_encoder = SimpleNamespace( + gated_cross_attn_layers=torch.nn.Linear(1, 1), + get_input_embeddings=lambda: torch.nn.Embedding(2, 2), + ) + + monkeypatch.setattr( + flamingo_module.AutoTokenizer, + "from_pretrained", + lambda *args, **kwargs: FakeTokenizer(), + ) + monkeypatch.setattr( + flamingo_module.AutoModelForCausalLM, + "from_pretrained", + lambda *args, **kwargs: GemmaForCausalLM(), + ) + monkeypatch.setattr( + flamingo_module, + "CNNTokenizer", + lambda: torch.nn.Identity(), + ) + monkeypatch.setattr(flamingo_module, "extend_instance", lambda *args, **kwargs: None) + monkeypatch.setattr( + flamingo_module, + "TimeSeriesFlamingoWithTrainableEncoder", + FakeFlamingoModel, + ) + + OpenTSLMFlamingo(device="cpu", llm_id="dummy") + + assert resize_calls == [(13, {"mean_resizing": False})] diff --git a/uv.lock b/uv.lock index f1efe58f..f08c43f5 100644 --- a/uv.lock +++ b/uv.lock @@ -2188,7 +2188,7 @@ requires-dist = [ { name = "scikit-learn", specifier = ">=1.0" }, { name = "torch", specifier = ">=2.0" }, { name = "tqdm", specifier = ">=4.62" }, - { name = "transformers", specifier = ">=4.25" }, + { name = "transformers", specifier = ">=4.46" }, { name = "wfdb", specifier = ">=4.0" }, ]