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,718 @@
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 logging
17
+ import math
18
+ import time
19
+ from contextlib import nullcontext
20
+ from functools import partial
21
+ from pathlib import Path
22
+ from collections.abc import Callable
23
+ import warnings
24
+
25
+ from importlib.metadata import version
26
+ import pandas as pd
27
+ import numpy as np
28
+ import torch
29
+ from opacus.grad_sample import register_grad_sampler
30
+ from torch import nn
31
+ from torch.nn import CrossEntropyLoss
32
+ from torch.optim.lr_scheduler import LRScheduler
33
+
34
+ from opacus import PrivacyEngine, GradSampleModule
35
+ from opacus.accountants import PRVAccountant, RDPAccountant, GaussianAccountant
36
+ from opacus.utils.batch_memory_manager import wrap_data_loader
37
+
38
+ from torch.utils.data import DataLoader
39
+
40
+ from mostlyai.engine._common import (
41
+ STRING,
42
+ ProgressCallback,
43
+ ProgressCallbackWrapper,
44
+ TABLE_COLUMN_INFIX,
45
+ )
46
+ from mostlyai.engine._language.common import (
47
+ is_bf16_supported,
48
+ load_base_model_and_config,
49
+ estimate_max_tokens,
50
+ MAX_LENGTH,
51
+ )
52
+ from mostlyai.engine._language.encoding import row_to_json
53
+ from mostlyai.engine._language.tokenizer_utils import (
54
+ train_tokenizer,
55
+ MostlyDataCollatorForLanguageModeling,
56
+ tokenize_fn,
57
+ )
58
+ from mostlyai.engine._training_utils import (
59
+ check_early_training_exit,
60
+ EarlyStopper,
61
+ ModelCheckpoint,
62
+ ProgressMessage,
63
+ )
64
+ from mostlyai.engine.domain import ModelStateStrategy, DifferentialPrivacyConfig
65
+ from mostlyai.engine._workspace import Workspace, ensure_workspace_dir
66
+ from datasets import load_dataset, DatasetDict, Dataset, disable_progress_bar
67
+ from transformers import (
68
+ AutoTokenizer,
69
+ PreTrainedModel,
70
+ )
71
+ from mostlyai.engine._language.lstm import LSTMFromScratchLMHeadModel, LSTMFromScratchConfig
72
+ from peft import LoraConfig, PeftModel
73
+
74
+ _LOG = logging.getLogger(__name__)
75
+
76
+
77
+ #####################
78
+ ### TRAINING LOOP ###
79
+ #####################
80
+
81
+
82
+ def _training_batch_size_heuristic(no_of_records: int, no_of_model_params: int, max_tokens: int) -> tuple[int, int]:
83
+ """
84
+ Calculate the physical batch size and gradient accumulation steps.
85
+
86
+ Args:
87
+ no_of_records (int): Number of records in the training dataset.
88
+ no_of_model_params (int): Number of model parameters.
89
+ max_tokens (int): Maximum number of tokens that are in the training dataset.
90
+
91
+ Returns:
92
+ Tuple[int, int]: A tuple containing:
93
+ - Batch size (int)
94
+ - Gradient accumulation steps (int)
95
+ """
96
+
97
+ if no_of_model_params < 10_000_000:
98
+ batch_size = 32
99
+ elif no_of_model_params < 2_000_000_000:
100
+ batch_size = 16 if max_tokens < 100 else 8
101
+ else:
102
+ batch_size = 8 if max_tokens < 100 else 4
103
+ gradient_accumulation_steps = 2
104
+ max_batch_size = no_of_records // gradient_accumulation_steps
105
+ batch_size = int(np.clip(a=batch_size, a_min=1, a_max=max_batch_size))
106
+ return batch_size, gradient_accumulation_steps
107
+
108
+
109
+ def _learn_rate_heuristic(no_of_model_params: int) -> float:
110
+ if no_of_model_params < 10_000_000:
111
+ learn_rate = 4e-4
112
+ else:
113
+ learn_rate = 2e-5
114
+ return learn_rate
115
+
116
+
117
+ @register_grad_sampler(nn.Linear)
118
+ def compute_linear_grad_sample_full_precision(
119
+ layer: nn.Linear, activations: list[torch.Tensor], backprops: torch.Tensor
120
+ ) -> dict[nn.Parameter, torch.Tensor]:
121
+ """
122
+ Overwrite the default backward hook for linear layer implemented in
123
+ https://github.com/pytorch/opacus/blob/main/opacus/grad_sample/linear.py#L29-L48
124
+
125
+ The difference is that this ensures activations and backprops are upcasted to float32 before the computation.
126
+ """
127
+ activations = activations[0]
128
+ ret = {}
129
+ if layer.weight.requires_grad:
130
+ gs = torch.einsum("n...i,n...j->nij", backprops.float(), activations.float())
131
+ ret[layer.weight] = gs
132
+ if layer.bias is not None and layer.bias.requires_grad:
133
+ ret[layer.bias] = torch.einsum("n...k->nk", backprops.float())
134
+ return ret
135
+
136
+
137
+ class LanguageModelCheckpoint(ModelCheckpoint):
138
+ def _save_model_weights(self, model: PreTrainedModel | GradSampleModule) -> None:
139
+ if isinstance(model, GradSampleModule):
140
+ # LSTMFromScratchLMHeadModel with DPLSTM layers can only be saved without safe serialization
141
+ # the weights will be saved as *.bin instead of .safetensors
142
+ safe_serialization = model._module.config.model_type != LSTMFromScratchConfig.model_type
143
+ model._module.save_pretrained(self.workspace.model_path, safe_serialization=safe_serialization)
144
+ else:
145
+ model.save_pretrained(self.workspace.model_path)
146
+
147
+ def _clear_model_weights(self) -> None:
148
+ patterns = ["*.safetensors", "*.bin", "*.json"]
149
+ files = [f for p in patterns for f in self.workspace.model_path.glob(p)]
150
+ for f in files:
151
+ f.unlink(missing_ok=True)
152
+
153
+ def model_weights_path_exists(self) -> bool:
154
+ return any(self.workspace.model_path.glob("*.safetensors")) or any(self.workspace.model_path.glob("*.bin"))
155
+
156
+
157
+ def _calculate_per_label_losses(
158
+ model: PreTrainedModel | GradSampleModule, step_data: dict
159
+ ) -> tuple[torch.Tensor, torch.Tensor]:
160
+ outputs = model(input_ids=step_data["input_ids"], attention_mask=step_data["attention_mask"])
161
+ logits = outputs.logits
162
+
163
+ labels = step_data["labels"]
164
+ # Shift so that tokens < n predict n
165
+ shift_logits = logits[..., :-1, :].contiguous()
166
+ shift_labels = labels[..., 1:].contiguous()
167
+ # Flatten the tokens
168
+ shift_logits = shift_logits.view(
169
+ -1, model.config.vocab_size if not isinstance(model, GradSampleModule) else model._module.config.vocab_size
170
+ )
171
+ shift_labels = shift_labels.view(-1)
172
+ # Ensure tensors are on the same device
173
+ shift_labels = shift_labels.to(shift_logits.device)
174
+ loss_fct = CrossEntropyLoss(reduction="sum")
175
+ loss = loss_fct(shift_logits, shift_labels)
176
+ labels_ignored = torch.sum(shift_labels == -100)
177
+ num_labels = shift_labels.numel() - labels_ignored
178
+ return loss, num_labels
179
+
180
+
181
+ @torch.no_grad()
182
+ def _calculate_val_loss(model: PreTrainedModel | GradSampleModule, val_dataloader: DataLoader) -> float:
183
+ device = model.device if not isinstance(model, GradSampleModule) else model._module.device
184
+ total_loss = torch.tensor(0, dtype=torch.float32, device=device)
185
+ total_num_labels = torch.tensor(0, dtype=torch.long, device=device)
186
+ model.eval()
187
+ for step_data in val_dataloader:
188
+ step_data = {k: v.to(device) for k, v in step_data.items()}
189
+ loss, num_labels = _calculate_per_label_losses(model, step_data)
190
+ total_loss += loss
191
+ total_num_labels += num_labels
192
+ model.train()
193
+ val_loss_avg = total_loss / total_num_labels
194
+ return val_loss_avg.item()
195
+
196
+
197
+ def train(
198
+ *,
199
+ model: str = "MOSTLY_AI/LSTMFromScratch-3m",
200
+ max_training_time: float = 60.0,
201
+ max_epochs: float = 100.0,
202
+ batch_size: int | None = None,
203
+ gradient_accumulation_steps: int | None = None,
204
+ enable_flexible_generation: bool = True,
205
+ differential_privacy: DifferentialPrivacyConfig | dict | None = None,
206
+ upload_model_data_callback: Callable | None = None,
207
+ model_state_strategy: ModelStateStrategy | str = ModelStateStrategy.reset,
208
+ device: torch.device | str | None = None,
209
+ workspace_dir: str | Path = "engine-ws",
210
+ update_progress: ProgressCallback | None = None,
211
+ ):
212
+ _LOG.info("TRAIN_LANGUAGE started")
213
+ t0_ = time.time()
214
+ workspace_dir = ensure_workspace_dir(workspace_dir)
215
+ workspace = Workspace(workspace_dir)
216
+
217
+ with ProgressCallbackWrapper(
218
+ update_progress, progress_messages_path=workspace.model_progress_messages_path
219
+ ) as progress:
220
+ _LOG.info(f"numpy={version('numpy')}, pandas={version('pandas')}")
221
+ _LOG.info(f"torch={version('torch')}, opacus={version('opacus')}")
222
+ _LOG.info(f"transformers={version('transformers')}, peft={version('peft')}")
223
+ device = (
224
+ torch.device(device)
225
+ if device is not None
226
+ else (torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu"))
227
+ )
228
+ _LOG.info(f"{device=}")
229
+ bf16_supported = is_bf16_supported(device)
230
+ _LOG.info(f"{bf16_supported=}")
231
+ use_mixed_precision = bf16_supported and model != LSTMFromScratchConfig.model_id
232
+ _LOG.info(f"{use_mixed_precision=}")
233
+
234
+ tgt_stats = workspace.tgt_stats.read()
235
+ trn_cnt = tgt_stats["no_of_training_records"]
236
+ val_cnt = tgt_stats["no_of_validation_records"]
237
+
238
+ # set defaults
239
+ model_id = model or LSTMFromScratchConfig.model_id
240
+ _LOG.info(f"{model_id=}")
241
+ _LOG.info(f"{enable_flexible_generation=}")
242
+ max_training_time = max(0.0, max_training_time * 60) # convert to seconds
243
+ _LOG.info(f"{max_training_time=}s")
244
+ max_epochs = max(0.0, max_epochs)
245
+ max_epochs_cap = math.ceil((trn_cnt + val_cnt) / 50)
246
+ if max_epochs_cap < max_epochs:
247
+ _LOG.info(f"{max_epochs=} -> max_epochs={max_epochs_cap} due to small sample size")
248
+ max_epochs = max_epochs_cap
249
+ else:
250
+ _LOG.info(f"{max_epochs=}")
251
+ with_dp = differential_privacy is not None
252
+ _LOG.info(f"{with_dp=}")
253
+ _LOG.info(f"{model_state_strategy=}")
254
+
255
+ # initialize callbacks
256
+ upload_model_data_callback = upload_model_data_callback or (lambda *args, **kwargs: None)
257
+
258
+ # the line below fixes issue with growing epoch time for later epochs
259
+ # https://discuss.pytorch.org/t/training-time-gets-slower-and-slower-on-cpu/145483
260
+ torch.set_flush_denormal(True)
261
+
262
+ # load raw encoded data
263
+ if check_early_training_exit(workspace=workspace, trn_cnt=trn_cnt, val_cnt=val_cnt):
264
+ empty_ds = Dataset.from_dict({"ctx": [], "tgt": []})
265
+ raw_dataset = DatasetDict({"train": empty_ds, "validation": empty_ds})
266
+ else:
267
+ data_files = {
268
+ "train": [str(f) for f in workspace.encoded_data_trn.fetch_all()],
269
+ "validation": [str(f) for f in workspace.encoded_data_val.fetch_all()],
270
+ }
271
+ disable_progress_bar()
272
+ raw_dataset = load_dataset("parquet", data_files=data_files)
273
+
274
+ def shuffle_tgt_columns(x):
275
+ x_tgt = pd.DataFrame([json.loads(x.pop("tgt"))], dtype=STRING) # convert to DataFrame
276
+ x_tgt = x_tgt.sample(frac=1, axis=1) # shuffle columns
277
+ x_tgt = row_to_json(
278
+ x_tgt.add_prefix("tgt" + TABLE_COLUMN_INFIX).squeeze(axis=0), is_target=True
279
+ ) # convert back to JSON
280
+ return x | {"tgt": x_tgt}
281
+
282
+ # shuffle target columns if flexible generation is enabled
283
+ anyorder_dataset = raw_dataset.map(shuffle_tgt_columns) if enable_flexible_generation else raw_dataset
284
+
285
+ def concat_prompt_and_response(x):
286
+ return {"content": "".join(x.values())}
287
+
288
+ # concatenate prompt and response to form the content
289
+ content_dataset = anyorder_dataset.map(
290
+ concat_prompt_and_response, remove_columns=anyorder_dataset["train"].column_names
291
+ )
292
+
293
+ tokenizer_args = {
294
+ "padding_side": "right",
295
+ "truncation_side": "right",
296
+ # these must be False at initialization, as we manually add them later in tokenize_fn
297
+ "add_bos_token": False,
298
+ "add_eos_token": False,
299
+ "legacy": True,
300
+ }
301
+
302
+ _LOG.info("create training model")
303
+ model_checkpoint = LanguageModelCheckpoint(workspace=workspace)
304
+ model: PreTrainedModel | PeftModel
305
+
306
+ # check how to handle existing model weights
307
+ if isinstance(model_state_strategy, str):
308
+ model_state_strategy = ModelStateStrategy(model_state_strategy)
309
+ if not model_checkpoint.model_weights_path_exists():
310
+ _LOG.info(f"model weights not found; change strategy from {model_state_strategy} to RESET")
311
+ model_state_strategy = ModelStateStrategy.reset
312
+ _LOG.info(f"{model_state_strategy=}")
313
+ if model_state_strategy in [ModelStateStrategy.resume, ModelStateStrategy.reuse]:
314
+ _LOG.info("load existing model weights")
315
+ torch.serialization.add_safe_globals([np.core.multiarray.scalar, np.dtype, np.dtypes.Float64DType])
316
+ resume_from_last_checkpoint = True
317
+ model_id_or_path = workspace.model_path
318
+ else: # ModelStateStrategy.reset
319
+ _LOG.info("clear existing checkpoint files")
320
+ model_checkpoint.clear_checkpoint()
321
+ resume_from_last_checkpoint = False
322
+ model_id_or_path = model
323
+
324
+ # check how to handle existing progress state
325
+ last_progress_message = progress.get_last_progress_message()
326
+ if last_progress_message and model_state_strategy == ModelStateStrategy.resume:
327
+ epoch = last_progress_message.get("epoch", 0.0)
328
+ steps = last_progress_message.get("steps", 0)
329
+ samples = last_progress_message.get("samples", 0)
330
+ initial_lr = last_progress_message.get("learn_rate", None)
331
+ total_time_init = last_progress_message.get("total_time", 0.0)
332
+ else:
333
+ epoch = 0.0
334
+ steps = 0
335
+ samples = 0
336
+ initial_lr = None
337
+ total_time_init = 0.0
338
+ progress.reset_progress_messages()
339
+ _LOG.info(f"start training progress from {epoch=}, {steps=}")
340
+
341
+ t0 = time.time()
342
+ if model == LSTMFromScratchConfig.model_id:
343
+ if resume_from_last_checkpoint:
344
+ tokenizer = AutoTokenizer.from_pretrained(model_id_or_path, **tokenizer_args)
345
+ model, _ = load_base_model_and_config(
346
+ model_id_or_path, device=device, is_peft_adapter=False, is_training=True
347
+ )
348
+ else:
349
+ # fresh initialization of the custom tokenizer and LSTM model
350
+ tokenizer_train_iter = (
351
+ content_dataset["train"][i : i + 1_000]["content"]
352
+ for i in range(0, len(content_dataset["train"]), 1_000)
353
+ )
354
+ # train a custom tokenizer and convert it to a LlamaTokenizerFast object
355
+ tokenizer = train_tokenizer(tokenizer_train_iter, tokenizer_kwargs=tokenizer_args)
356
+ model_config = LSTMFromScratchConfig(vocab_size=len(tokenizer), with_dp=with_dp)
357
+ model = LSTMFromScratchLMHeadModel(model_config).to(device)
358
+ else:
359
+ model, model_config = load_base_model_and_config(
360
+ model_id_or_path,
361
+ device=device,
362
+ is_peft_adapter=resume_from_last_checkpoint,
363
+ is_training=True,
364
+ )
365
+ tokenizer = AutoTokenizer.from_pretrained(model_id_or_path, **tokenizer_args)
366
+ if tokenizer.eos_token is None:
367
+ if getattr(model_config, "eos_token_id", None) is not None:
368
+ tokenizer.eos_token_id = model_config.eos_token_id
369
+ if tokenizer.bos_token is None:
370
+ if getattr(model_config, "bos_token_id", None) is not None:
371
+ tokenizer.bos_token_id = model_config.bos_token_id
372
+ else:
373
+ tokenizer.bos_token = tokenizer.eos_token
374
+ _LOG.warning("bos token not found, setting eos token as bos token")
375
+ if getattr(tokenizer, "pad_token", None) is None:
376
+ if getattr(tokenizer, "unk_token", None) is not None:
377
+ # warning: unk token can be valid output, although very unlikely for proper tokenizers
378
+ tokenizer.pad_token = tokenizer.unk_token
379
+ else:
380
+ _LOG.warning(
381
+ "pad_token not found and unk token not available as fallback, setting eos token as pad token -- this can result in eos being masked."
382
+ )
383
+ tokenizer.pad_token = tokenizer.eos_token
384
+ if resume_from_last_checkpoint:
385
+ model = PeftModel.from_pretrained(model, model_id_or_path, is_trainable=True)
386
+ else:
387
+ peft_config = LoraConfig(
388
+ lora_alpha=32, # 2x rank
389
+ lora_dropout=0.05,
390
+ r=16,
391
+ target_modules="all-linear",
392
+ bias="none",
393
+ task_type="CAUSAL_LM",
394
+ )
395
+ model.add_adapter(peft_config)
396
+
397
+ _LOG.info(f"model loading time: {time.time() - t0:.2f}s")
398
+ model.train()
399
+ no_of_model_params = model.num_parameters()
400
+ _LOG.info(f"{no_of_model_params=}")
401
+ no_of_trainable_model_params = model.num_parameters(only_trainable=True)
402
+ _LOG.info(f"{no_of_trainable_model_params=}")
403
+
404
+ _LOG.info(f"{tokenizer=}")
405
+ tokenizer.save_pretrained(workspace.model_path)
406
+
407
+ tokenized_datasets = content_dataset.map(
408
+ partial(
409
+ tokenize_fn,
410
+ tokenizer=tokenizer,
411
+ text_key="content",
412
+ add_bos_token=True,
413
+ add_eos_token=True,
414
+ padding=True,
415
+ truncation=True,
416
+ max_length=MAX_LENGTH,
417
+ ),
418
+ batched=True,
419
+ remove_columns=content_dataset["train"].column_names,
420
+ )
421
+ data_collator = MostlyDataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False)
422
+
423
+ max_tokens_estimate = estimate_max_tokens(tgt_stats)
424
+ default_batch_size, default_gradient_accumulation_steps = _training_batch_size_heuristic(
425
+ no_of_records=trn_cnt, no_of_model_params=no_of_model_params, max_tokens=max_tokens_estimate
426
+ )
427
+ if batch_size is None:
428
+ batch_size = default_batch_size
429
+ if gradient_accumulation_steps is None:
430
+ gradient_accumulation_steps = default_gradient_accumulation_steps
431
+
432
+ # setup params for input pipeline
433
+ batch_size = max(1, min(batch_size, trn_cnt))
434
+ gradient_accumulation_steps = max(1, min(gradient_accumulation_steps, trn_cnt // batch_size))
435
+ trn_batch_size = batch_size * gradient_accumulation_steps
436
+ trn_steps = max(1, trn_cnt // trn_batch_size)
437
+ val_batch_size = max(1, min(batch_size, val_cnt))
438
+ val_steps = max(1, val_cnt // val_batch_size)
439
+
440
+ if initial_lr is None:
441
+ initial_lr = _learn_rate_heuristic(no_of_model_params)
442
+
443
+ _LOG.info(f"{trn_cnt=}, {val_cnt=}")
444
+ _LOG.info(f"{trn_batch_size=}, {val_batch_size=}")
445
+ _LOG.info(f"{trn_steps=}, {val_steps=}")
446
+ _LOG.info(f"{batch_size=}, {gradient_accumulation_steps=}, {initial_lr=}")
447
+
448
+ # early exit if there is not enough data to train the model
449
+ if len(tokenized_datasets["train"]) == 0 or len(tokenized_datasets["validation"]) == 0:
450
+ _LOG.warning("not enough data to train model; skipping training")
451
+ model.save_pretrained(workspace.model_path)
452
+ return
453
+
454
+ trn_dataloader = DataLoader(
455
+ tokenized_datasets["train"],
456
+ shuffle=True,
457
+ # either DP logical batch size or grad accumulation physical batch size
458
+ batch_size=trn_batch_size if with_dp else batch_size,
459
+ collate_fn=data_collator,
460
+ )
461
+ val_dataloader = DataLoader(
462
+ tokenized_datasets["validation"],
463
+ shuffle=False,
464
+ batch_size=val_batch_size,
465
+ collate_fn=data_collator,
466
+ )
467
+
468
+ early_stopper = EarlyStopper(val_loss_patience=4)
469
+ optimizer = torch.optim.AdamW(params=model.parameters(), lr=initial_lr)
470
+ lr_scheduler: LRScheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
471
+ optimizer=optimizer,
472
+ factor=0.5,
473
+ patience=2,
474
+ min_lr=0.1 * initial_lr,
475
+ # threshold=0, # if we prefer to completely mimic the behavior of previous implementation
476
+ )
477
+ if (
478
+ model_state_strategy == ModelStateStrategy.resume
479
+ and model_checkpoint.optimizer_and_lr_scheduler_paths_exist()
480
+ ):
481
+ # restore the full states of optimizer and lr_scheduler when possible
482
+ # otherwise, only the learning rate from the last progress message will be restored
483
+ _LOG.info("restore optimizer and LR scheduler states")
484
+ optimizer.load_state_dict(
485
+ torch.load(workspace.model_optimizer_path, map_location=device, weights_only=True)
486
+ )
487
+ lr_scheduler.load_state_dict(
488
+ torch.load(workspace.model_lr_scheduler_path, map_location=device, weights_only=True)
489
+ )
490
+
491
+ if device.type == "cuda":
492
+ # this can help accelerate GPU compute
493
+ torch.backends.cudnn.benchmark = True
494
+
495
+ if with_dp:
496
+ if isinstance(differential_privacy, DifferentialPrivacyConfig):
497
+ dp_config = differential_privacy.model_dump()
498
+ else:
499
+ dp_config = DifferentialPrivacyConfig(**differential_privacy).model_dump()
500
+ dp_max_epsilon = dp_config.get("max_epsilon") or float("inf")
501
+ dp_delta = dp_config.get("delta", 1e-5)
502
+ # the implementation of PRV accountant seems to have numerical and memory issues for small noise multiplier
503
+ # therefore, we choose RDP instead as it is more stable and provides comparable privacy guarantees
504
+ dp_accountant = "rdp" # hard-coded for now
505
+ _LOG.info(f"{dp_config=}, {dp_accountant=}")
506
+ privacy_engine = PrivacyEngine(accountant=dp_accountant)
507
+ if model_state_strategy == ModelStateStrategy.resume and workspace.model_dp_accountant_path.exists():
508
+ _LOG.info("restore DP accountant state")
509
+ torch.serialization.add_safe_globals([getattr, PRVAccountant, RDPAccountant, GaussianAccountant])
510
+ privacy_engine.accountant.load_state_dict(
511
+ torch.load(workspace.model_dp_accountant_path, map_location=device, weights_only=True),
512
+ )
513
+ # Opacus will return the modified objects
514
+ # - model: wrapped in GradSampleModule and contains additional hooks for computing per-sample gradients
515
+ # - optimizer: wrapped in DPOptimizer and will do different operations during virtual steps and logical steps
516
+ # - dataloader: the dataloader with batch_sampler=UniformWithReplacementSampler (for Poisson sampling)
517
+ model, optimizer, trn_dataloader = privacy_engine.make_private(
518
+ module=model,
519
+ optimizer=optimizer,
520
+ data_loader=trn_dataloader,
521
+ noise_multiplier=dp_config.get("noise_multiplier"),
522
+ max_grad_norm=dp_config.get("max_grad_norm"),
523
+ poisson_sampling=True,
524
+ )
525
+ # this further wraps the dataloader with batch_sampler=BatchSplittingSampler to achieve gradient accumulation
526
+ # it will split the sampled logical batches into smaller sub-batches with batch_size
527
+ trn_dataloader = wrap_data_loader(
528
+ data_loader=trn_dataloader, max_batch_size=batch_size, optimizer=optimizer
529
+ )
530
+ else:
531
+ privacy_engine = None
532
+ dp_config, dp_delta, dp_accountant = None, None, None
533
+
534
+ progress_message = None
535
+ start_trn_time = time.time()
536
+ last_msg_time = time.time()
537
+ trn_data_iter = iter(trn_dataloader)
538
+ do_stop = False
539
+ current_lr = initial_lr
540
+ forward_ctx_mgr = (
541
+ torch.autocast(device_type=device.type, dtype=torch.bfloat16) if use_mixed_precision else nullcontext()
542
+ )
543
+ # infinite loop over training steps, until we decide to stop
544
+ # either because of max_epochs, max_training_time or early_stopping
545
+ while not do_stop:
546
+ is_checkpoint = 0
547
+ steps += 1
548
+ epoch = steps / trn_steps
549
+
550
+ stop_accumulating_grads = False
551
+ accumulated_steps = 0
552
+ if not with_dp:
553
+ optimizer.zero_grad(set_to_none=True)
554
+ while not stop_accumulating_grads:
555
+ # fetch next training (micro)batch
556
+ try:
557
+ step_data = next(trn_data_iter)
558
+ except StopIteration:
559
+ trn_data_iter = iter(trn_dataloader)
560
+ step_data = next(trn_data_iter)
561
+ step_data = {k: v.to(device) for k, v in step_data.items()}
562
+ if with_dp:
563
+ # opacus handles the gradient accumulation internally
564
+ optimizer.zero_grad(set_to_none=True)
565
+ with warnings.catch_warnings():
566
+ # remove this ctx mgr and filter when https://github.com/pytorch/pytorch/issues/130659 is fixed
567
+ warnings.filterwarnings("ignore", category=FutureWarning, message=".*torch.cpu.amp.autocast.*")
568
+ warnings.filterwarnings("ignore", category=FutureWarning, message="Using a non-full backward hook*")
569
+ # forward pass + calculate sample losses
570
+ with forward_ctx_mgr:
571
+ outputs = model(**step_data)
572
+ # FIXME approximation, should be divided by total sum of number of tokens in the batch
573
+ # as in _calculate_per_label_losses, also the final sample may be smaller than the batch size.
574
+ step_loss = outputs.loss / (1 if with_dp else gradient_accumulation_steps)
575
+ step_loss.backward()
576
+ accumulated_steps += 1
577
+ # explicitly count the number of processed samples as the actual batch size can vary when DP is on
578
+ samples += step_data["input_ids"].shape[0]
579
+ if with_dp:
580
+ # for DP training, the optimizer will do different operations during virtual steps and logical steps
581
+ # - virtual step: clip and accumulate gradients
582
+ # - logical step: clip and accumulate gradients, add noises to gradients and update parameters
583
+ optimizer.step()
584
+ # if step was not skipped, it was a logical step, and we can stop accumulating gradients
585
+ stop_accumulating_grads = not optimizer._is_last_step_skipped
586
+ elif accumulated_steps % gradient_accumulation_steps == 0:
587
+ # update parameters with accumulated gradients
588
+ optimizer.step()
589
+ stop_accumulating_grads = True
590
+ current_lr = optimizer.param_groups[0][
591
+ "lr"
592
+ ] # currently assume that we have the same lr for all param groups
593
+ # only the scheduling for ReduceLROnPlateau is postponed until the metric becomes available
594
+ if not isinstance(lr_scheduler, torch.optim.lr_scheduler.ReduceLROnPlateau):
595
+ lr_scheduler.step()
596
+
597
+ # do validation
598
+ do_validation = epoch.is_integer()
599
+ if do_validation:
600
+ # calculate val loss
601
+ with forward_ctx_mgr:
602
+ val_loss = _calculate_val_loss(model=model, val_dataloader=val_dataloader)
603
+ dp_epsilon = privacy_engine.get_epsilon(dp_delta) if with_dp else None
604
+ has_exceeded_dp_max_epsilon = dp_epsilon > dp_max_epsilon if with_dp else False
605
+ # save model weights with the best validation loss (and that hasn't exceeded DP max epsilon)
606
+ if not has_exceeded_dp_max_epsilon:
607
+ is_checkpoint = model_checkpoint.save_checkpoint_if_best(
608
+ val_loss=val_loss,
609
+ model=model,
610
+ optimizer=optimizer,
611
+ lr_scheduler=lr_scheduler,
612
+ dp_accountant=privacy_engine.accountant if with_dp else None,
613
+ )
614
+ else:
615
+ _LOG.info("early stopping: current DP epsilon has exceeded max epsilon")
616
+ # gather message for progress with checkpoint info
617
+ progress_message = ProgressMessage(
618
+ epoch=epoch,
619
+ is_checkpoint=is_checkpoint,
620
+ steps=steps,
621
+ samples=samples,
622
+ trn_loss=None,
623
+ val_loss=val_loss,
624
+ total_time=total_time_init + time.time() - start_trn_time,
625
+ learn_rate=current_lr,
626
+ dp_eps=dp_epsilon,
627
+ dp_delta=dp_delta,
628
+ )
629
+ # check for early stopping
630
+ do_stop = early_stopper(val_loss=val_loss) or has_exceeded_dp_max_epsilon
631
+ # scheduling for ReduceLROnPlateau
632
+ if isinstance(lr_scheduler, torch.optim.lr_scheduler.ReduceLROnPlateau):
633
+ lr_scheduler.step(metrics=val_loss)
634
+
635
+ # log progress, either by time or by steps, whatever is shorter
636
+ elapsed_training_time = time.time() - start_trn_time
637
+ estimated_time_for_max_epochs = (max_epochs * trn_steps) * (elapsed_training_time / steps)
638
+ if max_training_time < estimated_time_for_max_epochs:
639
+ # use seconds for measuring progress against max_training_time
640
+ progress_total_count = max_training_time
641
+ progress_processed = elapsed_training_time
642
+ else:
643
+ # use steps for measuring progress against max_epochs
644
+ progress_total_count = max_epochs * trn_steps
645
+ progress_processed = steps
646
+ # send a progress message at least every X minutes
647
+ last_msg_interval = 5 * 60
648
+ last_msg_elapsed = time.time() - last_msg_time
649
+ if progress_message is None and (last_msg_elapsed > last_msg_interval or steps == 1):
650
+ dp_epsilon = privacy_engine.get_epsilon(dp_delta) if with_dp else None
651
+ progress_message = ProgressMessage(
652
+ epoch=epoch,
653
+ is_checkpoint=is_checkpoint,
654
+ steps=steps,
655
+ samples=samples,
656
+ trn_loss=None,
657
+ val_loss=None,
658
+ total_time=total_time_init + time.time() - start_trn_time,
659
+ learn_rate=current_lr,
660
+ dp_eps=dp_epsilon,
661
+ dp_delta=dp_delta,
662
+ )
663
+ if progress_message:
664
+ last_msg_time = time.time()
665
+ # send progress update
666
+ res = progress.update(
667
+ completed=int(progress_processed),
668
+ total=int(progress_total_count),
669
+ message=progress_message,
670
+ )
671
+ if do_validation:
672
+ upload_model_data_callback()
673
+ progress_message = None
674
+ if (res or {}).get("stopExecution", False):
675
+ _LOG.info("received STOP EXECUTION signal")
676
+ do_stop = True
677
+
678
+ # check for max_epochs
679
+ if epoch > max_epochs:
680
+ do_stop = True
681
+
682
+ # check for max_training_time
683
+ if (total_time_init + time.time() - start_trn_time) > max_training_time:
684
+ do_stop = True
685
+
686
+ # no checkpoint is saved yet because the training stopped before the first epoch ended
687
+ if not model_checkpoint.has_saved_once():
688
+ _LOG.info("saving model weights, as none were saved so far")
689
+ model_checkpoint.save_checkpoint(
690
+ model=model,
691
+ optimizer=optimizer,
692
+ lr_scheduler=lr_scheduler,
693
+ dp_accountant=privacy_engine.accountant if with_dp else None,
694
+ )
695
+ with forward_ctx_mgr:
696
+ val_loss = _calculate_val_loss(model=model, val_dataloader=val_dataloader)
697
+ dp_epsilon = privacy_engine.get_epsilon(dp_delta) if with_dp else None
698
+ # send a final message to inform how far we've progressed
699
+ progress_message = ProgressMessage(
700
+ epoch=epoch,
701
+ is_checkpoint=1,
702
+ steps=steps,
703
+ samples=samples,
704
+ trn_loss=None,
705
+ val_loss=val_loss,
706
+ total_time=total_time_init + time.time() - start_trn_time,
707
+ learn_rate=current_lr,
708
+ dp_eps=dp_epsilon,
709
+ dp_delta=dp_delta,
710
+ )
711
+ progress.update(
712
+ completed=steps,
713
+ total=steps,
714
+ message=progress_message,
715
+ )
716
+ # ensure everything gets uploaded
717
+ upload_model_data_callback()
718
+ _LOG.info(f"TRAIN_LANGUAGE finished in {time.time() - t0_:.2f}s")