mostlyai-engine 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. mostlyai/engine/__init__.py +27 -0
  2. mostlyai/engine/_common.py +612 -0
  3. mostlyai/engine/_dtypes.py +58 -0
  4. mostlyai/engine/_encoding_types/__init__.py +13 -0
  5. mostlyai/engine/_encoding_types/language/__init__.py +13 -0
  6. mostlyai/engine/_encoding_types/language/text.py +41 -0
  7. mostlyai/engine/_encoding_types/tabular/__init__.py +13 -0
  8. mostlyai/engine/_encoding_types/tabular/categorical.py +115 -0
  9. mostlyai/engine/_encoding_types/tabular/character.py +155 -0
  10. mostlyai/engine/_encoding_types/tabular/datetime.py +254 -0
  11. mostlyai/engine/_encoding_types/tabular/itt.py +352 -0
  12. mostlyai/engine/_encoding_types/tabular/lat_long.py +353 -0
  13. mostlyai/engine/_encoding_types/tabular/numeric.py +548 -0
  14. mostlyai/engine/_language/__init__.py +17 -0
  15. mostlyai/engine/_language/common.py +110 -0
  16. mostlyai/engine/_language/encoding.py +162 -0
  17. mostlyai/engine/_language/engine/__init__.py +13 -0
  18. mostlyai/engine/_language/engine/base.py +51 -0
  19. mostlyai/engine/_language/engine/hf_engine.py +136 -0
  20. mostlyai/engine/_language/engine/vllm_engine.py +187 -0
  21. mostlyai/engine/_language/formatron_utils.py +114 -0
  22. mostlyai/engine/_language/generation.py +309 -0
  23. mostlyai/engine/_language/lstm.py +139 -0
  24. mostlyai/engine/_language/tokenizer_utils.py +144 -0
  25. mostlyai/engine/_language/training.py +718 -0
  26. mostlyai/engine/_memory.py +69 -0
  27. mostlyai/engine/_tabular/__init__.py +13 -0
  28. mostlyai/engine/_tabular/argn.py +1382 -0
  29. mostlyai/engine/_tabular/common.py +29 -0
  30. mostlyai/engine/_tabular/encoding.py +406 -0
  31. mostlyai/engine/_tabular/fairness.py +245 -0
  32. mostlyai/engine/_tabular/generation.py +1157 -0
  33. mostlyai/engine/_tabular/training.py +856 -0
  34. mostlyai/engine/_training_utils.py +159 -0
  35. mostlyai/engine/_workspace.py +225 -0
  36. mostlyai/engine/analysis.py +571 -0
  37. mostlyai/engine/domain.py +171 -0
  38. mostlyai/engine/encoding.py +42 -0
  39. mostlyai/engine/generation.py +110 -0
  40. mostlyai/engine/splitting.py +257 -0
  41. mostlyai/engine/training.py +104 -0
  42. mostlyai_engine-1.0.0.dist-info/METADATA +179 -0
  43. mostlyai_engine-1.0.0.dist-info/RECORD +45 -0
  44. mostlyai_engine-1.0.0.dist-info/WHEEL +4 -0
  45. mostlyai_engine-1.0.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,309 @@
1
+ # Copyright 2025 MOSTLY AI
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import json
16
+ import os
17
+
18
+ import json_repair
19
+ import logging
20
+ import time
21
+ from pathlib import Path
22
+
23
+ import pandas as pd
24
+ import torch
25
+ from huggingface_hub import constants as hf_constants
26
+ from transformers import (
27
+ PreTrainedTokenizerBase,
28
+ )
29
+
30
+ from mostlyai.engine._common import (
31
+ persist_data_part,
32
+ FixedSizeSampleBuffer,
33
+ STRING,
34
+ ProgressCallback,
35
+ ProgressCallbackWrapper,
36
+ )
37
+ from mostlyai.engine._language.common import estimate_max_tokens, MAX_LENGTH
38
+ from mostlyai.engine._language.encoding import encode_df
39
+ from mostlyai.engine._workspace import ensure_workspace_dir, Workspace, reset_dir
40
+ from mostlyai.engine._language.formatron_utils import (
41
+ get_formatter_builders,
42
+ prepare_seed_for_formatron,
43
+ get_vocab_processors,
44
+ )
45
+
46
+ INVALID_VALUE = "_INVALID_" # when JSON parsing fails, the values of target columns will be set to this
47
+ DUMMY_CONTEXT_KEY = "__dummy_context_key"
48
+ _LOG = logging.getLogger(__name__)
49
+
50
+
51
+ def decode_buffered_samples(
52
+ buffer: FixedSizeSampleBuffer,
53
+ tokenizer: PreTrainedTokenizerBase,
54
+ tgt_text_columns: list[str],
55
+ tgt_context_key: str,
56
+ max_new_tokens: int,
57
+ ):
58
+ t0 = time.time()
59
+
60
+ def parse_json(x, columns: list[str]):
61
+ try:
62
+ parsed_x = json_repair.loads(x)
63
+ if not isinstance(parsed_x, dict):
64
+ raise ValueError("parsed_x has to be a dictionary")
65
+ except (json.decoder.JSONDecodeError, ValueError):
66
+ parsed_x = {}
67
+ return [parsed_x.get(c, INVALID_VALUE) for c in columns]
68
+
69
+ ctx_keys = []
70
+ tgt_seed = []
71
+ output_texts = []
72
+ num_samples_max_length_limit = 0
73
+ for outputs_ids, keys_df, seed_df in buffer.buffer:
74
+ try:
75
+ num_tokens_by_row = [sum(token != tokenizer.eos_token_id for token in row) for row in outputs_ids]
76
+ num_samples_max_length_limit += sum(1 for tokens in num_tokens_by_row if tokens >= max_new_tokens)
77
+ except AttributeError:
78
+ num_samples_max_length_limit = float("-inf")
79
+ outputs_text = tokenizer.batch_decode(outputs_ids, skip_special_tokens=True)
80
+ output_texts.extend(outputs_text)
81
+ ctx_keys.append(keys_df)
82
+ tgt_seed.append(seed_df)
83
+ _LOG.info(f"{num_samples_max_length_limit=}")
84
+ ctx_keys = pd.concat(ctx_keys, axis=0).reset_index(drop=True).rename(tgt_context_key)
85
+ tgt_seed = pd.concat(tgt_seed, axis=0).reset_index(drop=True)
86
+ # The model works with un-prefixed column names, but we need to recover prefixed column names for the final output
87
+ tgt_data = pd.DataFrame(
88
+ [parse_json(text, tgt_text_columns) for text in output_texts],
89
+ columns=tgt_text_columns,
90
+ index=ctx_keys.index,
91
+ dtype="string",
92
+ )
93
+ # make sure invalid/incomplete unicode chars are replaced with the replacement char � (U+FFFD)
94
+ tgt_data = tgt_data.map(
95
+ lambda x: x.encode("utf-8", errors="surrogatepass").decode("utf-8", errors="replace") if not pd.isna(x) else x
96
+ )
97
+ # overwrite generated columns with the seeded values
98
+ tgt_data.update(tgt_seed)
99
+ # ensure STRING type
100
+ tgt_data = tgt_data.astype(STRING)
101
+ # prepend the context keys to the data (if not dummy context)
102
+ if ctx_keys.name != DUMMY_CONTEXT_KEY:
103
+ tgt_data = pd.concat([ctx_keys, tgt_data], axis=1)
104
+ invalid_percentage = ((tgt_data[tgt_text_columns] == INVALID_VALUE).sum() / len(tgt_data) * 100.0).map(
105
+ "{:.2f}%".format
106
+ )
107
+ _LOG.info(f"percentage of invalid values: {invalid_percentage.to_dict()}")
108
+ _LOG.info(f"decoded {tgt_data.shape} from {len(buffer.buffer)} batches in {time.time() - t0:.2f}s")
109
+ return tgt_data
110
+
111
+
112
+ def generate(
113
+ *,
114
+ ctx_data: pd.DataFrame | None = None,
115
+ seed_data: pd.DataFrame | None = None,
116
+ sample_size: int | None = None,
117
+ batch_size: int | None = None,
118
+ sampling_temperature: float = 1.0,
119
+ sampling_top_p: float = 1.0,
120
+ device: torch.device | str | None = None,
121
+ workspace_dir: str | Path = "engine-ws",
122
+ update_progress: ProgressCallback | None = None,
123
+ ):
124
+ _LOG.info("GENERATE_LANGUAGE started")
125
+ t0_ = time.time()
126
+ with ProgressCallbackWrapper(update_progress) as progress:
127
+ device = (
128
+ torch.device(device)
129
+ if device is not None
130
+ else (torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu"))
131
+ )
132
+ _LOG.info(f"{device=}")
133
+ _LOG.info(f"{sampling_temperature=}, {sampling_top_p=}")
134
+
135
+ workspace_dir = ensure_workspace_dir(workspace_dir)
136
+ workspace = Workspace(workspace_dir)
137
+ output_path = workspace.generated_data_path
138
+ reset_dir(output_path)
139
+ tgt_stats = workspace.tgt_stats.read()
140
+ tgt_text_columns = list(tgt_stats["columns"].keys())
141
+ tgt_context_key = tgt_stats["keys"].get("context_key")
142
+ has_context = workspace.ctx_stats.path.exists()
143
+
144
+ # resolve potential conflict between sample_seed and sample_size
145
+ if seed_data is not None:
146
+ assert sample_size is None, "either sample_seed or sample_size can be provided, not both"
147
+ sample_size = len(seed_data)
148
+
149
+ if has_context:
150
+ ctx_stats = workspace.ctx_stats.read()
151
+ ctx_columns = list(ctx_stats["columns"].keys())
152
+ ctx_primary_key = ctx_stats["keys"].get("primary_key")
153
+
154
+ # ensure ctx_data exists
155
+ if ctx_data is None:
156
+ if workspace.ctx_data_path.exists():
157
+ # attempt to re-use context from training, if no new context provided
158
+ ctx_data = pd.read_parquet(workspace.ctx_data_path)
159
+ else:
160
+ # build dummy context; fallback to using training data size as sample_size
161
+ trn_sample_size = tgt_stats["no_of_training_records"] + tgt_stats["no_of_validation_records"]
162
+ ctx_data = pd.DataFrame({ctx_primary_key: list(range(sample_size or trn_sample_size))})
163
+ _LOG.info(f"{ctx_data.shape=}")
164
+ ctx_data = ctx_data.reset_index(drop=True)
165
+ ctx_data_len = len(ctx_data)
166
+ if sample_size is not None and sample_size < ctx_data_len:
167
+ # take first `sample_size` rows of context
168
+ ctx_data = ctx_data.head(sample_size)
169
+ _LOG.info(f"dropped {ctx_data_len - len(ctx_data)} rows from context data")
170
+ _LOG.info(f"{ctx_data.shape=}")
171
+
172
+ # update sample_size based on fetched context data
173
+ sample_size = len(ctx_data)
174
+ _LOG.info(f"{sample_size=}")
175
+ else:
176
+ # create on-the-fly context
177
+ if sample_size is None:
178
+ trn_sample_size = tgt_stats["no_of_training_records"] + tgt_stats["no_of_validation_records"]
179
+ sample_size = trn_sample_size if sample_size is None else sample_size
180
+ ctx_columns = []
181
+ ctx_primary_key = tgt_context_key = DUMMY_CONTEXT_KEY
182
+ ctx_data = pd.DataFrame({ctx_primary_key: range(sample_size)})
183
+
184
+ # ensure sample_seed exists; ensure valid columns
185
+ if seed_data is None:
186
+ # build dummy seed
187
+ seed_data = pd.DataFrame(index=list(range(sample_size)))
188
+ seed_data = seed_data[[c for c in tgt_text_columns if c in seed_data.columns]]
189
+ _LOG.info(f"{seed_data.shape=}")
190
+
191
+ # sanity check: at this point sample seed and context data should have the same number of rows
192
+ assert len(seed_data) == len(ctx_data)
193
+
194
+ # early exit in case generation context is empty
195
+ if sample_size == 0:
196
+ _LOG.info("terminating generation early as no context data provided")
197
+ empty_out_df = pd.DataFrame(columns=[tgt_context_key] + tgt_text_columns, dtype="string")
198
+ persist_data_part(empty_out_df, output_path, f"{0:06}.{0:06}")
199
+ return
200
+
201
+ # encode context data
202
+ encoded_ctx_data = encode_df(ctx_df=ctx_data, ctx_columns=ctx_columns)
203
+
204
+ # estimate max new tokens based on char length of original data; consider JSON overhead
205
+ max_new_tokens = estimate_max_tokens(tgt_stats)
206
+ _LOG.info(f"{max_new_tokens=}")
207
+
208
+ t0 = time.time()
209
+ hf_constants.HF_HUB_OFFLINE = (
210
+ False # needed for gated hf models that are not sharded, otherwise GatedRepoError in vLLM
211
+ )
212
+ # set the default env var so that we don't pass it explicitly to vLLM
213
+ os.environ["HF_TOKEN"] = os.getenv("MOSTLY_HUGGING_FACE_TOKEN", "")
214
+
215
+ is_peft_adapter = (workspace.model_path / "adapter_config.json").exists()
216
+ if is_peft_adapter and device.type == "cuda":
217
+ from mostlyai.engine._language.engine.vllm_engine import VLLMEngine
218
+
219
+ engine = VLLMEngine(workspace.model_path, device, max_new_tokens, MAX_LENGTH)
220
+ else:
221
+ from mostlyai.engine._language.engine.hf_engine import HuggingFaceEngine
222
+
223
+ engine = HuggingFaceEngine(workspace.model_path, device, max_new_tokens, MAX_LENGTH)
224
+ _LOG.info(f"inference engine: {engine.__class__.__name__}")
225
+
226
+ batch_size = batch_size or engine.get_default_batch_size()
227
+ _LOG.info(f"model loading time: {time.time() - t0:.2f}s")
228
+
229
+ if batch_size > sample_size:
230
+ batch_size = sample_size
231
+ _LOG.info(f"{batch_size=}")
232
+
233
+ # prepare seed data for clean consumption by formatron
234
+ seed_data = prepare_seed_for_formatron(seed_data, engine.tokenizer)
235
+ seeded_tgt_columns = seed_data.columns.to_list()
236
+ unseeded_tgt_columns = [c for c in tgt_text_columns if c not in seeded_tgt_columns]
237
+
238
+ total_tokenize_fn_time = 0
239
+ total_logits_processor_build_time = 0
240
+ total_generate_fn_time = 0
241
+
242
+ enforce_json_output = engine.supports_json_enforcing()
243
+ _LOG.info(f"{enforce_json_output=}")
244
+ formatron_vocab_processors = get_vocab_processors(is_peft_adapter)
245
+
246
+ if enforce_json_output and len(seeded_tgt_columns) == 0:
247
+ t0 = time.time()
248
+ formatter_builders = get_formatter_builders(size=batch_size, unseeded_fields=unseeded_tgt_columns)
249
+ engine.initialize_logits_processors(formatter_builders, formatron_vocab_processors)
250
+ total_logits_processor_build_time += time.time() - t0
251
+
252
+ # keep at most 500k samples in memory before decoding and writing to disk
253
+ buffer = FixedSizeSampleBuffer(capacity=500_000)
254
+
255
+ progress.update(completed=0, total=sample_size)
256
+ samples_processed = 0
257
+ while samples_processed < sample_size:
258
+ encoded_ctx_batch = encoded_ctx_data.iloc[samples_processed : samples_processed + batch_size]
259
+ sample_seed_batch = seed_data.iloc[samples_processed : samples_processed + batch_size]
260
+ ctx_batch = ctx_data.iloc[samples_processed : samples_processed + batch_size]
261
+ ctx_keys = ctx_batch[ctx_primary_key]
262
+
263
+ if enforce_json_output and len(seeded_tgt_columns) > 0:
264
+ t0 = time.time()
265
+ # some columns are seeded, so we need to create a new logits processor for each batch
266
+ formatter_builders = get_formatter_builders(
267
+ seed_df=sample_seed_batch,
268
+ unseeded_fields=unseeded_tgt_columns,
269
+ )
270
+ engine.initialize_logits_processors(formatter_builders, formatron_vocab_processors)
271
+ total_logits_processor_build_time += time.time() - t0
272
+
273
+ outputs, metrics = engine.generate(
274
+ encoded_ctx_batch["ctx"].tolist(),
275
+ sampling_temperature=sampling_temperature,
276
+ sampling_top_p=sampling_top_p,
277
+ )
278
+ total_tokenize_fn_time += metrics.tokenize_time
279
+ total_generate_fn_time += metrics.generate_time
280
+
281
+ buffer.add((outputs, ctx_keys, sample_seed_batch))
282
+ if buffer.is_full():
283
+ decoded_data = decode_buffered_samples(
284
+ buffer, engine.tokenizer, tgt_text_columns, tgt_context_key, max_new_tokens
285
+ )
286
+ persist_data_part(
287
+ decoded_data,
288
+ output_path,
289
+ f"{buffer.n_clears:06}.{0:06}",
290
+ )
291
+ buffer.clear()
292
+ progress.update(advance=len(ctx_batch))
293
+ samples_processed += len(ctx_batch)
294
+
295
+ if not buffer.is_empty():
296
+ decoded_data = decode_buffered_samples(
297
+ buffer, engine.tokenizer, tgt_text_columns, tgt_context_key, max_new_tokens
298
+ )
299
+ persist_data_part(
300
+ decoded_data,
301
+ output_path,
302
+ f"{buffer.n_clears:06}.{0:06}",
303
+ )
304
+ buffer.clear()
305
+ _LOG.info(f"{total_tokenize_fn_time=:.2f}s")
306
+ _LOG.info(f"{total_logits_processor_build_time=:.2f}s")
307
+ _LOG.info(f"{total_generate_fn_time=:.2f}s")
308
+ engine.cleanup()
309
+ _LOG.info(f"GENERATE_LANGUAGE finished in {time.time() - t0_:.2f}s")
@@ -0,0 +1,139 @@
1
+ # Copyright 2025 MOSTLY AI
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import logging
16
+
17
+ import torch
18
+ from transformers import PreTrainedModel, PretrainedConfig, AutoConfig, AutoModelForCausalLM, AutoModel, GenerationMixin
19
+ from transformers.modeling_outputs import CausalLMOutput
20
+ import torch.nn as nn
21
+
22
+ _LOG = logging.getLogger(__name__)
23
+
24
+
25
+ class LSTMFromScratchConfig(PretrainedConfig):
26
+ model_type = model_id = "MOSTLY_AI/LSTMFromScratch-3m"
27
+
28
+ def __init__(
29
+ self,
30
+ vocab_size: int | None = None,
31
+ embedding_size: int = 256,
32
+ hidden_size: int = 256,
33
+ num_layers: int = 1,
34
+ dropout: float = 0.25,
35
+ with_dp: bool = False,
36
+ **kwargs,
37
+ ):
38
+ self.vocab_size = vocab_size
39
+ self.embedding_size = embedding_size
40
+ self.hidden_size = hidden_size
41
+ self.num_layers = num_layers
42
+ self.dropout = dropout
43
+ self.with_dp = with_dp
44
+ super().__init__(**kwargs)
45
+
46
+
47
+ class LSTMFromScratchLMHeadModel(PreTrainedModel, GenerationMixin):
48
+ config_class = LSTMFromScratchConfig
49
+
50
+ def __init__(self, config: LSTMFromScratchConfig):
51
+ super().__init__(config)
52
+ self.config = config
53
+
54
+ self.embedding = nn.Embedding(self.config.vocab_size, self.config.embedding_size)
55
+ self.dropout = nn.Dropout(self.config.dropout)
56
+ if self.config.with_dp:
57
+ from opacus.layers import DPLSTM
58
+
59
+ lstm_cls = DPLSTM
60
+ else:
61
+ lstm_cls = nn.LSTM
62
+ self.lstm = lstm_cls(
63
+ input_size=self.config.embedding_size,
64
+ hidden_size=self.config.hidden_size,
65
+ num_layers=self.config.num_layers,
66
+ dropout=self.config.dropout if self.config.num_layers > 1 else 0.0,
67
+ batch_first=True,
68
+ )
69
+ self.lm_head = nn.Linear(self.config.hidden_size, self.config.vocab_size)
70
+ self.loss_fn = nn.CrossEntropyLoss()
71
+
72
+ # this will be filled by left_to_right_padding() during the generation
73
+ self.pad_token_id = None
74
+
75
+ def forward(
76
+ self,
77
+ input_ids: torch.Tensor,
78
+ attention_mask: torch.Tensor,
79
+ labels: torch.Tensor | None = None,
80
+ **kwargs,
81
+ ) -> CausalLMOutput:
82
+ lengths = attention_mask.sum(dim=1)
83
+ embeddings = self.embedding(input_ids)
84
+ embeddings = self.dropout(embeddings)
85
+
86
+ # (DP)LSTM layers without pack_padded_sequence/pad_packed_sequence
87
+ lstm_outputs, _ = self.lstm(embeddings)
88
+
89
+ logits = self.lm_head(lstm_outputs)
90
+
91
+ loss = None
92
+ if labels is not None:
93
+ labels = labels[:, 1:].contiguous()
94
+ shifted_prediction_scores = logits[:, :-1, :].contiguous()
95
+ loss = self.loss_fn(shifted_prediction_scores.view(-1, self.config.vocab_size), labels.view(-1))
96
+ else:
97
+ # overwrite the logit of the last time step with the logit of the actual last token
98
+ # so that Hugging Face Transformers' generate() will sample on the right probabilities
99
+ logits[:, -1, :] = torch.stack([logits[i, length - 1, :] for i, length in enumerate(lengths)])
100
+ return CausalLMOutput(
101
+ loss=loss,
102
+ logits=logits,
103
+ )
104
+
105
+ def prepare_inputs_for_generation(
106
+ self, input_ids: torch.Tensor, attention_mask: torch.Tensor, **kwargs
107
+ ) -> dict[str, torch.Tensor]:
108
+ """
109
+ This function is mandatory so that the model is able to use the Hugging Face `.generate()` method.
110
+ Since `.generate()` works with left-padded sequences but the model is trained with right-padded sequences,
111
+ we need to convert the padding side here to make it work properly.
112
+ """
113
+ lengths = attention_mask.sum(dim=1)
114
+ return {
115
+ "input_ids": self.left_to_right_padding(input_ids, lengths),
116
+ "attention_mask": attention_mask,
117
+ }
118
+
119
+ def left_to_right_padding(self, left_padded_tensors: torch.Tensor, lengths: torch.Tensor) -> torch.Tensor:
120
+ batch_size, max_length = left_padded_tensors.size()
121
+ indices = torch.nonzero(lengths < max_length)
122
+ if len(indices) == 0:
123
+ # none of the samples are padded, so we can just return them as they are
124
+ return left_padded_tensors
125
+ else:
126
+ if self.pad_token_id is None:
127
+ # get the pad token id from the first padded sample
128
+ self.pad_token_id = left_padded_tensors[indices[0], -1].item()
129
+ right_padded_tensors = torch.full_like(left_padded_tensors, self.pad_token_id)
130
+ for i in range(batch_size):
131
+ right_padded_tensors[i, : lengths[i]] = left_padded_tensors[i, max_length - lengths[i] :]
132
+ return right_padded_tensors
133
+
134
+
135
+ def register_mostly_lstm_model():
136
+ # register the model so that we can load it with `AutoModelForCausalLM.from_pretrained()` later
137
+ AutoConfig.register(LSTMFromScratchConfig.model_id, LSTMFromScratchConfig)
138
+ AutoModel.register(LSTMFromScratchConfig, LSTMFromScratchLMHeadModel)
139
+ AutoModelForCausalLM.register(LSTMFromScratchConfig, LSTMFromScratchLMHeadModel)
@@ -0,0 +1,144 @@
1
+ # Copyright 2025 MOSTLY AI
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+ from collections.abc import Mapping, Iterator
18
+
19
+ from transformers import DataCollatorForLanguageModeling, BatchEncoding, PreTrainedTokenizerFast, LlamaTokenizerFast
20
+ from transformers.data.data_collator import pad_without_fast_tokenizer_warning, _torch_collate_batch
21
+
22
+
23
+ #################
24
+ ### TOKENIZER ###
25
+ #################
26
+
27
+
28
+ def train_tokenizer(training_iterator: Iterator | list | None = None, tokenizer_kwargs=None):
29
+ if tokenizer_kwargs is None:
30
+ tokenizer_kwargs = {}
31
+ from tokenizers import Tokenizer, decoders
32
+ from tokenizers.models import BPE
33
+ from tokenizers.normalizers import Replace
34
+ from tokenizers.pre_tokenizers import Metaspace, Punctuation, Sequence, Split
35
+ from tokenizers.trainers import BpeTrainer
36
+
37
+ special_tokens = {
38
+ "unk_token": "<unk>",
39
+ "pad_token": "<pad>",
40
+ "bos_token": "<s>",
41
+ "eos_token": "</s>",
42
+ }
43
+ SPECIAL_TOKENS = list(special_tokens.values())
44
+ NEW_LINE_VALUE = "\n"
45
+ NEW_LINE_SYMBOL = "\u240a" # https://www.fileformat.info/info/unicode/char/240a/index.htm
46
+ MIN_FREQ_MERGE = 20
47
+ VOCAB_SIZE = 5000
48
+
49
+ # Builds a BPE raw_tokenizer, and optionally trains it based on provided text
50
+ training_iterator = training_iterator or [] # allow easy training skip
51
+ raw_tokenizer = Tokenizer(BPE(unk_token=special_tokens["unk_token"]))
52
+ trainer = BpeTrainer(
53
+ special_tokens=SPECIAL_TOKENS,
54
+ min_frequency=MIN_FREQ_MERGE,
55
+ vocab_size=VOCAB_SIZE,
56
+ show_progress=False,
57
+ )
58
+ raw_tokenizer.normalizer = Replace(NEW_LINE_VALUE, NEW_LINE_SYMBOL)
59
+ raw_tokenizer.pre_tokenizer = Sequence(
60
+ [
61
+ Metaspace(),
62
+ Split(pattern=NEW_LINE_SYMBOL, behavior="isolated"),
63
+ Punctuation(),
64
+ ]
65
+ )
66
+ raw_tokenizer.decoder = decoders.Sequence(
67
+ [
68
+ decoders.Metaspace(),
69
+ decoders.Replace(NEW_LINE_SYMBOL, NEW_LINE_VALUE),
70
+ ]
71
+ )
72
+ raw_tokenizer.train_from_iterator(iterator=training_iterator, trainer=trainer)
73
+ tokenizer = LlamaTokenizerFast(tokenizer_object=raw_tokenizer, **special_tokens, **tokenizer_kwargs)
74
+ return tokenizer
75
+
76
+
77
+ def tokenize_fn(
78
+ text: dict[str, str] | dict[str, list[str]] | list[str],
79
+ tokenizer: PreTrainedTokenizerFast,
80
+ text_key: str | None = None,
81
+ return_tensors: str | None = None,
82
+ padding: bool | str = True,
83
+ truncation: bool = True,
84
+ add_bos_token: bool = True,
85
+ add_eos_token: bool = True,
86
+ max_length: int = 1024,
87
+ ) -> BatchEncoding:
88
+ if text_key:
89
+ text = text[text_key]
90
+ # make sure the tokenizer is configured as expected
91
+ if getattr(tokenizer, "add_bos_token", False) or getattr(tokenizer, "add_eos_token", False):
92
+ raise RuntimeError("Tokenizer must be configured as add_bos_token=False and add_eos_token=False")
93
+ if tokenizer.bos_token is None or tokenizer.eos_token is None:
94
+ raise RuntimeError("Tokenizer must have bos_token and eos_token set")
95
+ prefix = tokenizer.bos_token if add_bos_token else ""
96
+ suffix = tokenizer.eos_token if add_eos_token else ""
97
+ # NOTE: here we add bos/eos tokens before truncation and padding,
98
+ # which means that they may be truncated for long sequences
99
+ if isinstance(text, str):
100
+ text = f"{prefix}{text}{suffix}"
101
+ else:
102
+ for i, t in enumerate(text):
103
+ text[i] = f"{prefix}{t}{suffix}"
104
+ tokenized_content = tokenizer(
105
+ text,
106
+ padding=padding,
107
+ truncation=truncation,
108
+ max_length=max_length,
109
+ return_tensors=return_tensors,
110
+ )
111
+ return tokenized_content
112
+
113
+
114
+ #####################
115
+ ### DATA COLLATOR ###
116
+ #####################
117
+
118
+
119
+ @dataclass
120
+ class MostlyDataCollatorForLanguageModeling(DataCollatorForLanguageModeling):
121
+ def torch_call(self, examples: list[list[int] | Any | dict[str, Any]]) -> dict[str, Any]:
122
+ """
123
+ A variation of the original `DataCollatorForLanguageModeling.torch_call` method.
124
+
125
+ This method can mask tokens based on the attention mask, so that bos and eos tokens will not be masked
126
+ even if they are identical to pad token.
127
+ If attention mask is not provided, it will fall back to masking pad tokens.
128
+ """
129
+ if isinstance(examples[0], Mapping):
130
+ batch = pad_without_fast_tokenizer_warning(
131
+ self.tokenizer, examples, return_tensors="pt", pad_to_multiple_of=None
132
+ )
133
+ else:
134
+ batch = {"input_ids": _torch_collate_batch(examples, self.tokenizer, pad_to_multiple_of=None)}
135
+
136
+ labels = batch["input_ids"].clone()
137
+ attention_mask = batch.get("attention_mask", None)
138
+ if attention_mask is not None:
139
+ labels[(attention_mask == 0)] = -100
140
+ else:
141
+ if self.tokenizer.pad_token_id is not None:
142
+ labels[labels == self.tokenizer.pad_token_id] = -100
143
+ batch["labels"] = labels
144
+ return batch