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.
- mostlyai/engine/__init__.py +27 -0
- mostlyai/engine/_common.py +612 -0
- mostlyai/engine/_dtypes.py +58 -0
- mostlyai/engine/_encoding_types/__init__.py +13 -0
- mostlyai/engine/_encoding_types/language/__init__.py +13 -0
- mostlyai/engine/_encoding_types/language/text.py +41 -0
- mostlyai/engine/_encoding_types/tabular/__init__.py +13 -0
- mostlyai/engine/_encoding_types/tabular/categorical.py +115 -0
- mostlyai/engine/_encoding_types/tabular/character.py +155 -0
- mostlyai/engine/_encoding_types/tabular/datetime.py +254 -0
- mostlyai/engine/_encoding_types/tabular/itt.py +352 -0
- mostlyai/engine/_encoding_types/tabular/lat_long.py +353 -0
- mostlyai/engine/_encoding_types/tabular/numeric.py +548 -0
- mostlyai/engine/_language/__init__.py +17 -0
- mostlyai/engine/_language/common.py +110 -0
- mostlyai/engine/_language/encoding.py +162 -0
- mostlyai/engine/_language/engine/__init__.py +13 -0
- mostlyai/engine/_language/engine/base.py +51 -0
- mostlyai/engine/_language/engine/hf_engine.py +136 -0
- mostlyai/engine/_language/engine/vllm_engine.py +187 -0
- mostlyai/engine/_language/formatron_utils.py +114 -0
- mostlyai/engine/_language/generation.py +309 -0
- mostlyai/engine/_language/lstm.py +139 -0
- mostlyai/engine/_language/tokenizer_utils.py +144 -0
- mostlyai/engine/_language/training.py +718 -0
- mostlyai/engine/_memory.py +69 -0
- mostlyai/engine/_tabular/__init__.py +13 -0
- mostlyai/engine/_tabular/argn.py +1382 -0
- mostlyai/engine/_tabular/common.py +29 -0
- mostlyai/engine/_tabular/encoding.py +406 -0
- mostlyai/engine/_tabular/fairness.py +245 -0
- mostlyai/engine/_tabular/generation.py +1157 -0
- mostlyai/engine/_tabular/training.py +856 -0
- mostlyai/engine/_training_utils.py +159 -0
- mostlyai/engine/_workspace.py +225 -0
- mostlyai/engine/analysis.py +571 -0
- mostlyai/engine/domain.py +171 -0
- mostlyai/engine/encoding.py +42 -0
- mostlyai/engine/generation.py +110 -0
- mostlyai/engine/splitting.py +257 -0
- mostlyai/engine/training.py +104 -0
- mostlyai_engine-1.0.0.dist-info/METADATA +179 -0
- mostlyai_engine-1.0.0.dist-info/RECORD +45 -0
- mostlyai_engine-1.0.0.dist-info/WHEEL +4 -0
- 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
|