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,856 @@
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
+ import math
17
+ import time
18
+ import warnings
19
+ from itertools import zip_longest
20
+ from pathlib import Path
21
+ from collections.abc import Callable
22
+
23
+ from importlib.metadata import version
24
+ import numpy as np
25
+ import pandas as pd
26
+ import torch
27
+ from datasets import load_dataset, disable_progress_bar
28
+ from torch.utils.data import DataLoader
29
+ from torch.optim.lr_scheduler import LRScheduler
30
+
31
+ from torch import nn
32
+
33
+ from opacus import PrivacyEngine, GradSampleModule
34
+ from opacus.accountants import PRVAccountant, RDPAccountant, GaussianAccountant
35
+ from opacus.utils.batch_memory_manager import wrap_data_loader
36
+
37
+ from mostlyai.engine._memory import get_available_ram_for_heuristics
38
+ from mostlyai.engine.domain import ModelStateStrategy, DifferentialPrivacyConfig
39
+ from mostlyai.engine._tabular.argn import (
40
+ FlatModel,
41
+ ModelSize,
42
+ SequentialModel,
43
+ get_model_units,
44
+ get_no_of_model_parameters,
45
+ )
46
+ from mostlyai.engine._common import (
47
+ CTXFLT,
48
+ CTXSEQ,
49
+ TGT,
50
+ get_cardinalities,
51
+ get_columns_from_cardinalities,
52
+ get_ctx_sequence_length,
53
+ get_max_data_points_per_sample,
54
+ get_sequence_length_stats,
55
+ get_sub_columns_from_cardinalities,
56
+ get_sub_columns_nested_from_cardinalities,
57
+ SIDX_SUB_COLUMN_PREFIX,
58
+ SLEN_SUB_COLUMN_PREFIX,
59
+ SDEC_SUB_COLUMN_PREFIX,
60
+ ProgressCallback,
61
+ ProgressCallbackWrapper,
62
+ )
63
+ from mostlyai.engine._tabular.common import load_model_weights
64
+ from mostlyai.engine._training_utils import (
65
+ check_early_training_exit,
66
+ EarlyStopper,
67
+ ModelCheckpoint,
68
+ ProgressMessage,
69
+ )
70
+ from mostlyai.engine._workspace import Workspace, ensure_workspace_dir
71
+
72
+ _LOG = logging.getLogger(__name__)
73
+
74
+
75
+ ##################
76
+ ### HEURISTICS ###
77
+ ##################
78
+
79
+
80
+ def _training_batch_size_heuristic(
81
+ mem_available_gb: float,
82
+ no_of_records: int,
83
+ no_tgt_data_points: int,
84
+ no_ctx_data_points: int,
85
+ no_of_model_params: int,
86
+ ) -> tuple[int, int]:
87
+ """
88
+ Calculate the physical batch size and gradient accumulation steps.
89
+
90
+ Args:
91
+ mem_available_gb (float): Available memory in GB.
92
+ no_of_records (int): Number of records in the training dataset.
93
+ no_tgt_data_points (int): Number of target data points per sample.
94
+ no_ctx_data_points (int): Number of context data points per sample.
95
+ no_of_model_params (int): Number of model parameters.
96
+
97
+ Returns:
98
+ Tuple[int, int]: A tuple containing:
99
+ - Batch size (int)
100
+ - Gradient accumulation steps (int)
101
+ """
102
+ data_points = no_tgt_data_points + no_ctx_data_points
103
+ min_batch_size = 8
104
+ # scale batch_size corresponding to available memory
105
+ if mem_available_gb >= 32:
106
+ mem_scale = 2.0
107
+ elif mem_available_gb >= 8:
108
+ mem_scale = 1.0
109
+ else:
110
+ mem_scale = 0.5
111
+ # set max_batch_size corresponding to available memory, model params and data points
112
+ if no_of_model_params > 1_000_000_000 or data_points > 100_000:
113
+ max_batch_size = int(8 * mem_scale)
114
+ elif no_of_model_params > 100_000_000 or data_points > 10_000:
115
+ max_batch_size = int(32 * mem_scale)
116
+ elif no_of_model_params > 10_000_000 or data_points > 1_000:
117
+ max_batch_size = int(128 * mem_scale)
118
+ elif no_of_model_params > 1_000_000 or data_points > 100:
119
+ max_batch_size = int(512 * mem_scale)
120
+ else:
121
+ max_batch_size = int(2048 * mem_scale)
122
+ # ensure a minimum number of batches to avoid excessive padding
123
+ min_batches = 64
124
+ batch_size = 2 ** int(np.log2(no_of_records / min_batches)) if no_of_records > 0 else min_batch_size
125
+ return int(np.clip(a=batch_size, a_min=min_batch_size, a_max=max_batch_size)), 1
126
+
127
+
128
+ def _learn_rate_heuristic(batch_size: int) -> float:
129
+ learn_rate = np.round(0.001 * np.sqrt(batch_size / 32), 5)
130
+ return learn_rate
131
+
132
+
133
+ ####################
134
+ ### DATA LOADERS ###
135
+ ####################
136
+
137
+
138
+ class BatchCollator:
139
+ """
140
+ Collate a batch of samples into a dictionary of tensors.
141
+ For sequence data, it will sample subsequences with lengths up to max_sequence_window.
142
+ """
143
+
144
+ def __init__(self, is_sequential: bool, max_sequence_window: int | None, device: torch.device):
145
+ self.is_sequential = is_sequential
146
+ self.max_sequence_window = max_sequence_window
147
+ self.device = device
148
+
149
+ def __call__(self, batch: list[dict]) -> dict[str, torch.Tensor]:
150
+ batch = pd.DataFrame(batch)
151
+ if self.is_sequential and self.max_sequence_window:
152
+ batch = self._slice_sequences(batch, self.max_sequence_window)
153
+ batch = self._convert_to_tensors(batch)
154
+ return batch
155
+
156
+ def _convert_to_tensors(self, batch: pd.DataFrame) -> dict[str, torch.Tensor]:
157
+ tensors = {}
158
+ for column in batch.columns:
159
+ if column.startswith(TGT) and self.is_sequential:
160
+ # construct column tensor in single step
161
+ tensors[column] = torch.unsqueeze(
162
+ torch.tensor(
163
+ # pad batch-wise to the longest sequence length with 0s
164
+ np.array(list(zip_longest(*batch[column], fillvalue=0))).T,
165
+ dtype=torch.int64,
166
+ device=self.device,
167
+ ),
168
+ dim=-1,
169
+ )
170
+ elif column.startswith(TGT) and not self.is_sequential:
171
+ # construct column tensor in single step
172
+ tensors[column] = torch.unsqueeze(
173
+ torch.tensor(batch[column].values, dtype=torch.int64, device=self.device),
174
+ dim=-1,
175
+ )
176
+ elif column.startswith(CTXFLT):
177
+ # construct column tensor in single step
178
+ tensors[column] = torch.unsqueeze(
179
+ torch.tensor(batch[column].values, dtype=torch.int64, device=self.device),
180
+ dim=-1,
181
+ )
182
+ elif column.startswith(CTXSEQ):
183
+ # construct row tensors and convert the list to nested column tensor
184
+ tensors[column] = torch.unsqueeze(
185
+ torch.nested.as_nested_tensor(
186
+ [torch.tensor(row, dtype=torch.int64, device=self.device) for row in batch[column]],
187
+ dtype=torch.int64,
188
+ device=self.device,
189
+ ),
190
+ dim=-1,
191
+ )
192
+ return tensors
193
+
194
+ @staticmethod
195
+ def _slice_sequences(batch: pd.DataFrame, max_sequence_window: int) -> pd.DataFrame:
196
+ # determine sequence lengths of current batch
197
+ tgt_columns = [col for col in batch.columns if col.startswith(TGT)]
198
+ seq_lens = batch[tgt_columns[0]].copy().str.len().values
199
+
200
+ # determine sampling logic for current batch
201
+ flip = np.random.random()
202
+ if flip < 0.3: # 30%
203
+ # pick start of the sequence to focus on the beginning
204
+ sel_idxs = [np.arange(0, min(max_sequence_window, seq_len)) for seq_len in seq_lens]
205
+ elif 0.3 <= flip < 0.4: # 10%
206
+ # pick end of the sequence to focus on the end
207
+ sel_idxs = [np.arange(max(0, seq_len - max_sequence_window), seq_len) for seq_len in seq_lens]
208
+ else: # 60%
209
+ # random continuous window to focus on any part
210
+ start_idxs = np.random.randint(low=1 - max_sequence_window, high=seq_lens, size=len(seq_lens))
211
+ # ensure that sequences that fit into max_sequence_length are completely covered
212
+ start_idxs[seq_lens <= max_sequence_window] = 0
213
+ # calculate final start and end indexes
214
+ end_idxs = start_idxs + max_sequence_window
215
+ start_idxs = np.maximum(0, start_idxs)
216
+ sel_idxs = [
217
+ np.arange(start, min(seq_len, end)) for start, end, seq_len in zip(start_idxs, end_idxs, seq_lens)
218
+ ]
219
+
220
+ # loop over each record within batch and pick values for each tgt column
221
+ tgt_col_idxs = [batch.columns.get_loc(c) for c in tgt_columns]
222
+ rows = []
223
+ for row_idx, batch_row in enumerate(batch.itertuples(index=False)):
224
+ cells = []
225
+ for col_idx, batch_cell in enumerate(batch_row):
226
+ if col_idx in tgt_col_idxs:
227
+ cells.append([batch_cell[i] for i in sel_idxs[row_idx]])
228
+ else:
229
+ cells.append(batch_cell)
230
+ rows.append(cells)
231
+
232
+ return pd.DataFrame(rows, columns=batch.columns, index=batch.index)
233
+
234
+
235
+ #####################
236
+ ### TRAINING LOOP ###
237
+ #####################
238
+
239
+
240
+ class TabularModelCheckpoint(ModelCheckpoint):
241
+ def _save_model_weights(self, model: torch.nn.Module):
242
+ if isinstance(model, GradSampleModule):
243
+ state_dict = model._module.state_dict()
244
+ else:
245
+ state_dict = model.state_dict()
246
+ torch.save(state_dict, self.workspace.model_tabular_weights_path)
247
+
248
+ def _clear_model_weights(self) -> None:
249
+ self.workspace.model_tabular_weights_path.unlink(missing_ok=True)
250
+
251
+ def model_weights_path_exists(self) -> bool:
252
+ return self.workspace.model_tabular_weights_path.exists()
253
+
254
+
255
+ def _calculate_sample_losses(
256
+ model: FlatModel | SequentialModel | GradSampleModule, data: dict[str, torch.Tensor]
257
+ ) -> torch.Tensor:
258
+ with warnings.catch_warnings():
259
+ warnings.filterwarnings("ignore", category=FutureWarning, message="Using a non-full backward hook*")
260
+ output, _ = model(data, mode="trn")
261
+ criterion = nn.CrossEntropyLoss(reduction="none")
262
+
263
+ tgt_cols = (
264
+ list(model.tgt_cardinalities.keys())
265
+ if not isinstance(model, GradSampleModule)
266
+ else model._module.tgt_cardinalities.keys()
267
+ )
268
+ if isinstance(model, SequentialModel) or (
269
+ isinstance(model, GradSampleModule) and isinstance(model._module, SequentialModel)
270
+ ):
271
+ slen_cols = [k for k in data if k.startswith(SLEN_SUB_COLUMN_PREFIX)]
272
+
273
+ # generate masks for SLEN and time step
274
+ slen_mask = torch.zeros_like(data[slen_cols[0]], dtype=torch.int64)
275
+ for slen_col in slen_cols:
276
+ slen_mask |= data[slen_col] != 0 # mask loss for padded rows, which have SLEN=0
277
+ slen_mask = slen_mask.squeeze(-1)
278
+ time_step_mask = torch.zeros_like(slen_mask, dtype=torch.int64)
279
+ time_step_mask[:, 0] = 10 # mask loss for all time steps except the first one, and emphasize that one by 10x
280
+
281
+ # calculate per column losses
282
+ sidx_cols = {k for k in data if k.startswith(SIDX_SUB_COLUMN_PREFIX)}
283
+ sdec_cols = {k for k in data if k.startswith(SDEC_SUB_COLUMN_PREFIX)}
284
+ losses_by_column = []
285
+ for col in tgt_cols:
286
+ if col in slen_cols:
287
+ # mask out SLEN for steps > 1
288
+ mask = time_step_mask
289
+ elif col in sidx_cols or col in sdec_cols:
290
+ # SIDX and SDEC columns need to be present in the computation graph for DP to work
291
+ # so we're only masking them instead of skipping them completely
292
+ mask = torch.zeros_like(slen_mask, dtype=torch.int64)
293
+ else:
294
+ # mask out paddings
295
+ mask = slen_mask
296
+
297
+ column_loss = criterion(output[col].transpose(1, 2), data[col].squeeze(2))
298
+ masked_loss = torch.sum(column_loss * mask, dim=1) / torch.clamp(torch.sum(mask, dim=1), min=1)
299
+ losses_by_column.append(masked_loss)
300
+ else:
301
+ losses_by_column = [criterion(output[col], data[col].squeeze(1)) for col in tgt_cols]
302
+ # sum up column level losses to get overall losses at sample level
303
+ losses = torch.sum(torch.stack(losses_by_column, dim=0), dim=0)
304
+ return losses
305
+
306
+
307
+ # gradient tracking is not needed for validation steps, disable it to save memory
308
+ @torch.no_grad()
309
+ def _calculate_val_loss(
310
+ model: FlatModel | SequentialModel,
311
+ val_dataloader: DataLoader,
312
+ ) -> float:
313
+ val_sample_losses: list[torch.Tensor] = []
314
+ model.eval()
315
+ for step_data in val_dataloader:
316
+ step_losses = _calculate_sample_losses(model, step_data)
317
+ val_sample_losses.extend(step_losses.detach())
318
+ model.train()
319
+ val_sample_losses: torch.Tensor = torch.stack(val_sample_losses, dim=0)
320
+ val_loss_avg = torch.mean(val_sample_losses).item()
321
+ return val_loss_avg
322
+
323
+
324
+ def _calculate_average_trn_loss(trn_sample_losses: list[torch.Tensor], n: int | None = None) -> float | None:
325
+ if len(trn_sample_losses) == 0:
326
+ return None
327
+ trn_losses_latest = torch.stack(trn_sample_losses, dim=0)
328
+ if n is not None:
329
+ trn_losses_latest = trn_losses_latest[-n:]
330
+ trn_loss = torch.mean(trn_losses_latest).item()
331
+ return trn_loss
332
+
333
+
334
+ ################
335
+ ### TRAINING ###
336
+ ################
337
+
338
+
339
+ def train(
340
+ *,
341
+ model: str = "MOSTLY_AI/Medium",
342
+ max_training_time: float = 60.0,
343
+ max_epochs: float = 100.0,
344
+ batch_size: int | None = None,
345
+ gradient_accumulation_steps: int | None = None,
346
+ max_sequence_window: int = 100,
347
+ enable_flexible_generation: bool = True,
348
+ differential_privacy: DifferentialPrivacyConfig | dict | None = None,
349
+ upload_model_data_callback: Callable | None = None,
350
+ model_state_strategy: ModelStateStrategy | str = ModelStateStrategy.reset,
351
+ device: torch.device | str | None = None,
352
+ workspace_dir: str | Path = "engine-ws",
353
+ update_progress: ProgressCallback | None = None,
354
+ ):
355
+ _LOG.info("TRAIN_TABULAR started")
356
+ t0 = time.time()
357
+ workspace_dir = ensure_workspace_dir(workspace_dir)
358
+ workspace = Workspace(workspace_dir)
359
+ with ProgressCallbackWrapper(
360
+ update_progress, progress_messages_path=workspace.model_progress_messages_path
361
+ ) as progress:
362
+ _LOG.info(f"numpy={version('numpy')}, pandas={version('pandas')}")
363
+ _LOG.info(f"torch={version('torch')}, opacus={version('opacus')}")
364
+ device = (
365
+ torch.device(device)
366
+ if device is not None
367
+ else (torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu"))
368
+ )
369
+ _LOG.info(f"{device=}")
370
+ torch.set_default_dtype(torch.float32)
371
+
372
+ has_context = workspace.ctx_stats.path.exists()
373
+ tgt_stats = workspace.tgt_stats.read()
374
+ ctx_stats = workspace.ctx_stats.read()
375
+ is_sequential = tgt_stats["is_sequential"]
376
+ _LOG.info(f"{is_sequential=}")
377
+ trn_cnt = tgt_stats["no_of_training_records"]
378
+ val_cnt = tgt_stats["no_of_validation_records"]
379
+ tgt_cardinalities = get_cardinalities(tgt_stats)
380
+ ctx_cardinalities = get_cardinalities(ctx_stats) if has_context else {}
381
+ tgt_sub_columns = get_sub_columns_from_cardinalities(tgt_cardinalities)
382
+ ctx_nested_sub_columns = get_sub_columns_nested_from_cardinalities(ctx_cardinalities, "processor")
383
+ ctxflt_sub_columns = ctx_nested_sub_columns.get(CTXFLT, [])
384
+ ctxseq_sub_columns = ctx_nested_sub_columns.get(CTXSEQ, [])
385
+
386
+ # set defaults
387
+ max_training_time = max(0.0, max_training_time) * 60 # convert to seconds
388
+ _LOG.info(f"{max_training_time=}s")
389
+ max_epochs = max(0.0, max_epochs)
390
+ max_epochs_cap = math.ceil((trn_cnt + val_cnt) / 50)
391
+ if max_epochs_cap < max_epochs:
392
+ _LOG.info(f"{max_epochs=} -> max_epochs={max_epochs_cap} due to small sample size")
393
+ max_epochs = max_epochs_cap
394
+ else:
395
+ _LOG.info(f"{max_epochs=}")
396
+ model_sizes = {
397
+ "MOSTLY_AI/Small": ModelSize.S,
398
+ "MOSTLY_AI/Medium": ModelSize.M,
399
+ "MOSTLY_AI/Large": ModelSize.L,
400
+ }
401
+ if model not in model_sizes:
402
+ raise ValueError(f"model {model} not supported")
403
+ model_size = model_sizes[model]
404
+ _LOG.info(f"{model_size=}")
405
+ _LOG.info(f"{enable_flexible_generation=}")
406
+ with_dp = differential_privacy is not None
407
+ _LOG.info(f"{with_dp=}")
408
+ _LOG.info(f"{model_state_strategy=}")
409
+
410
+ # initialize callbacks
411
+ upload_model_data_callback = upload_model_data_callback or (lambda *args, **kwargs: None)
412
+
413
+ # early exit if there is not enough data to train the model
414
+ # in such scenario, training model is not created
415
+ # and weights are not stored, so generation must be resilient to that
416
+ if check_early_training_exit(workspace=workspace, trn_cnt=trn_cnt, val_cnt=val_cnt):
417
+ _LOG.warning("not enough data to train model; skipping training")
418
+ return
419
+
420
+ # determine column order for training
421
+ if enable_flexible_generation:
422
+ # random column order for each batch
423
+ trn_column_order = None
424
+ else:
425
+ # fixed column order based on cardinalities
426
+ tgt_cardinalities = get_cardinalities(tgt_stats)
427
+ trn_column_order = get_columns_from_cardinalities(tgt_cardinalities)
428
+
429
+ # gather sequence length stats for heuristics
430
+ tgt_seq_len_stats = get_sequence_length_stats(tgt_stats)
431
+ tgt_seq_len_deciles = tgt_seq_len_stats["deciles"]
432
+ tgt_seq_len_median = tgt_seq_len_stats["median"]
433
+ tgt_seq_len_max = tgt_seq_len_stats["max"]
434
+ max_sequence_window = np.clip(max_sequence_window, a_min=1, a_max=tgt_seq_len_max)
435
+ _LOG.info(f"{max_sequence_window=}")
436
+ _LOG.info(f"{tgt_seq_len_deciles=}")
437
+ ctx_seq_deciles = get_ctx_sequence_length(ctx_stats, key="deciles")
438
+ ctx_seq_len_median = get_ctx_sequence_length(ctx_stats, key="median")
439
+ _LOG.info(f"{ctx_seq_deciles=}")
440
+
441
+ # the line below fixes issue with growing epoch time for later epochs
442
+ # https://discuss.pytorch.org/t/training-time-gets-slower-and-slower-on-cpu/145483
443
+ torch.set_flush_denormal(True)
444
+
445
+ _LOG.info("create training model")
446
+ model_checkpoint = TabularModelCheckpoint(workspace=workspace)
447
+ argn: SequentialModel | FlatModel
448
+ if is_sequential:
449
+ argn = SequentialModel(
450
+ tgt_cardinalities=tgt_cardinalities,
451
+ ctx_cardinalities=ctx_cardinalities,
452
+ tgt_seq_len_median=tgt_seq_len_median,
453
+ tgt_seq_len_max=tgt_seq_len_max,
454
+ ctxseq_len_median=ctx_seq_len_median,
455
+ model_size=model_size,
456
+ column_order=trn_column_order,
457
+ device=device,
458
+ with_dp=with_dp, # this flag decides whether the model is initialized with LSTM or DPLSTM layers
459
+ )
460
+ else:
461
+ argn = FlatModel(
462
+ tgt_cardinalities=tgt_cardinalities,
463
+ ctx_cardinalities=ctx_cardinalities,
464
+ ctxseq_len_median=ctx_seq_len_median,
465
+ model_size=model_size,
466
+ column_order=trn_column_order,
467
+ device=device,
468
+ )
469
+ _LOG.info(f"model class: {argn.__class__.__name__}")
470
+
471
+ if isinstance(model_state_strategy, str):
472
+ model_state_strategy = ModelStateStrategy(model_state_strategy)
473
+ if not model_checkpoint.model_weights_path_exists():
474
+ _LOG.info(f"model weights not found; change strategy from {model_state_strategy} to RESET")
475
+ model_state_strategy = ModelStateStrategy.reset
476
+ _LOG.info(f"{model_state_strategy=}")
477
+ if model_state_strategy in [ModelStateStrategy.resume, ModelStateStrategy.reuse]:
478
+ _LOG.info("load existing model weights")
479
+ torch.serialization.add_safe_globals([np.core.multiarray.scalar, np.dtype, np.dtypes.Float64DType])
480
+ load_model_weights(model=argn, path=workspace.model_tabular_weights_path, device=device)
481
+ else: # ModelStateStrategy.reset
482
+ _LOG.info("remove existing checkpoint files")
483
+ model_checkpoint.clear_checkpoint()
484
+
485
+ # check how to handle existing progress state
486
+ last_progress_message = progress.get_last_progress_message()
487
+ if last_progress_message and model_state_strategy == ModelStateStrategy.resume:
488
+ epoch = last_progress_message.get("epoch", 0.0)
489
+ steps = last_progress_message.get("steps", 0)
490
+ samples = last_progress_message.get("samples", 0)
491
+ initial_lr = last_progress_message.get("learn_rate", None)
492
+ total_time_init = last_progress_message.get("total_time", 0.0)
493
+ else:
494
+ epoch = 0.0
495
+ steps = 0
496
+ samples = 0
497
+ initial_lr = None
498
+ total_time_init = 0.0
499
+ progress.reset_progress_messages()
500
+ _LOG.info(f"start training progress from {epoch=}, {steps=}")
501
+
502
+ argn.to(device)
503
+ no_of_model_params = get_no_of_model_parameters(argn)
504
+ _LOG.info(f"{no_of_model_params=}")
505
+
506
+ # persist model configs
507
+ model_units = get_model_units(argn)
508
+ model_configs = {
509
+ "model_id": model,
510
+ "model_units": model_units,
511
+ "enable_flexible_generation": enable_flexible_generation,
512
+ }
513
+ workspace.model_tabular_configs.write(model_configs)
514
+
515
+ # heuristics for batch_size and for initial learn_rate
516
+ mem_available_gb = get_available_ram_for_heuristics() / 1024**3
517
+ no_tgt_data_points = get_max_data_points_per_sample(tgt_stats)
518
+ no_ctx_data_points = get_max_data_points_per_sample(ctx_stats)
519
+ default_batch_size, default_gradient_accumulation_steps = _training_batch_size_heuristic(
520
+ mem_available_gb=mem_available_gb,
521
+ no_of_records=trn_cnt,
522
+ no_tgt_data_points=no_tgt_data_points,
523
+ no_ctx_data_points=no_ctx_data_points,
524
+ no_of_model_params=no_of_model_params,
525
+ )
526
+ if batch_size is None:
527
+ batch_size = default_batch_size
528
+ if gradient_accumulation_steps is None:
529
+ gradient_accumulation_steps = default_gradient_accumulation_steps
530
+
531
+ # setup params for input pipeline
532
+ batch_size = max(1, min(batch_size, trn_cnt))
533
+ gradient_accumulation_steps = max(1, min(gradient_accumulation_steps, trn_cnt // batch_size))
534
+ trn_batch_size = batch_size * gradient_accumulation_steps
535
+ trn_steps = max(1, trn_cnt // trn_batch_size)
536
+ val_batch_size = max(1, min(batch_size, val_cnt))
537
+ val_steps = max(1, val_cnt // val_batch_size)
538
+
539
+ if initial_lr is None:
540
+ initial_lr = _learn_rate_heuristic(trn_batch_size)
541
+ if is_sequential:
542
+ # reduce val_batch_size to reduce padding for validation batches,
543
+ # which speeds up compute, plus it results in a more stable val_loss
544
+ val_batch_size = val_batch_size // 2
545
+
546
+ # and see if it's possible to make it compatible with DP
547
+ batch_collator = BatchCollator(
548
+ is_sequential=is_sequential, max_sequence_window=max_sequence_window, device=device
549
+ )
550
+ disable_progress_bar()
551
+ trn_dataset = load_dataset("parquet", data_files=[str(p) for p in workspace.encoded_data_trn.fetch_all()])[
552
+ "train"
553
+ ]
554
+ trn_dataloader = DataLoader(
555
+ dataset=trn_dataset,
556
+ shuffle=True,
557
+ # either DP logical batch size or grad accumulation physical batch size
558
+ batch_size=trn_batch_size if with_dp else batch_size,
559
+ collate_fn=batch_collator,
560
+ )
561
+ val_dataset = load_dataset("parquet", data_files=[str(p) for p in workspace.encoded_data_val.fetch_all()])[
562
+ "train"
563
+ ]
564
+ val_dataloader = DataLoader(
565
+ dataset=val_dataset,
566
+ shuffle=False,
567
+ batch_size=val_batch_size,
568
+ collate_fn=batch_collator,
569
+ )
570
+
571
+ _LOG.info(f"{trn_cnt=}, {val_cnt=}")
572
+ _LOG.info(f"{len(tgt_sub_columns)=}, {len(ctxflt_sub_columns)=}, {len(ctxseq_sub_columns)=}")
573
+ if len(tgt_cardinalities) > 0:
574
+ tgt_cardinalities_deciles = list(
575
+ np.quantile(
576
+ list(tgt_cardinalities.values()),
577
+ np.arange(0, 1.1, 0.1),
578
+ method="lower",
579
+ )
580
+ )
581
+ _LOG.info(f"{tgt_cardinalities_deciles=}")
582
+ if len(ctx_cardinalities) > 0:
583
+ ctx_cardinalities_deciles = list(
584
+ np.quantile(
585
+ list(ctx_cardinalities.values()),
586
+ np.arange(0, 1.1, 0.1),
587
+ method="lower",
588
+ )
589
+ )
590
+ _LOG.info(f"{ctx_cardinalities_deciles=}")
591
+ _LOG.info(f"{trn_batch_size=}, {val_batch_size=}")
592
+ _LOG.info(f"{trn_steps=}, {val_steps=}")
593
+ _LOG.info(f"{batch_size=}, {gradient_accumulation_steps=}, {initial_lr=}")
594
+
595
+ early_stopper = EarlyStopper(val_loss_patience=4)
596
+ optimizer = torch.optim.AdamW(params=argn.parameters(), lr=initial_lr)
597
+ lr_scheduler: LRScheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
598
+ optimizer=optimizer,
599
+ factor=0.5,
600
+ patience=2,
601
+ min_lr=0.1 * initial_lr,
602
+ # threshold=0, # if we prefer to completely mimic the behavior of previous implementation
603
+ )
604
+ if (
605
+ model_state_strategy == ModelStateStrategy.resume
606
+ and model_checkpoint.optimizer_and_lr_scheduler_paths_exist()
607
+ ):
608
+ # restore the full states of optimizer and lr_scheduler when possible
609
+ # otherwise, only the learning rate from the last progress message will be restored
610
+ _LOG.info("restore optimizer and LR scheduler states")
611
+ optimizer.load_state_dict(
612
+ torch.load(workspace.model_optimizer_path, map_location=device, weights_only=True)
613
+ )
614
+ lr_scheduler.load_state_dict(
615
+ torch.load(workspace.model_lr_scheduler_path, map_location=device, weights_only=True)
616
+ )
617
+
618
+ if device.type == "cuda":
619
+ # this can help accelerate GPU compute
620
+ torch.backends.cudnn.benchmark = True
621
+
622
+ if with_dp:
623
+ if isinstance(differential_privacy, DifferentialPrivacyConfig):
624
+ dp_config = differential_privacy.model_dump()
625
+ else:
626
+ dp_config = DifferentialPrivacyConfig(**differential_privacy).model_dump()
627
+ dp_max_epsilon = dp_config.get("max_epsilon") or float("inf")
628
+ dp_delta = dp_config.get("delta", 1e-5)
629
+ # the implementation of PRV accountant seems to have numerical and memory issues for small noise multiplier
630
+ # therefore, we choose RDP instead as it is more stable and provides comparable privacy guarantees
631
+ dp_accountant = "rdp" # hard-coded for now
632
+ _LOG.info(f"{dp_config=}, {dp_accountant=}")
633
+ privacy_engine = PrivacyEngine(accountant=dp_accountant)
634
+ if model_state_strategy == ModelStateStrategy.resume and workspace.model_dp_accountant_path.exists():
635
+ _LOG.info("restore DP accountant state")
636
+ torch.serialization.add_safe_globals([getattr, PRVAccountant, RDPAccountant, GaussianAccountant])
637
+ privacy_engine.accountant.load_state_dict(
638
+ torch.load(workspace.model_dp_accountant_path, map_location=device, weights_only=True)
639
+ )
640
+ # Opacus will return the modified objects
641
+ # - model: wrapped in GradSampleModule and contains additional hooks for computing per-sample gradients
642
+ # - optimizer: wrapped in DPOptimizer and will do different operations during virtual steps and logical steps
643
+ # - dataloader: the dataloader with batch_sampler=UniformWithReplacementSampler (for Poisson sampling)
644
+ argn, optimizer, trn_dataloader = privacy_engine.make_private(
645
+ module=argn,
646
+ optimizer=optimizer,
647
+ data_loader=trn_dataloader,
648
+ noise_multiplier=dp_config.get("noise_multiplier"),
649
+ max_grad_norm=dp_config.get("max_grad_norm"),
650
+ poisson_sampling=True,
651
+ )
652
+ # this further wraps the dataloader with batch_sampler=BatchSplittingSampler to achieve gradient accumulation
653
+ # it will split the sampled logical batches into smaller sub-batches with batch_size
654
+ trn_dataloader = wrap_data_loader(
655
+ data_loader=trn_dataloader, max_batch_size=batch_size, optimizer=optimizer
656
+ )
657
+ else:
658
+ privacy_engine = None
659
+ dp_config, dp_delta, dp_accountant = None, None, None
660
+
661
+ progress_message = None
662
+ start_trn_time = time.time()
663
+ last_msg_time = time.time()
664
+ trn_data_iter = iter(trn_dataloader)
665
+ trn_sample_losses: list[torch.Tensor] = []
666
+ do_stop = False
667
+ current_lr = initial_lr
668
+ # infinite loop over training steps, until we decide to stop
669
+ # either because of max_epochs, max_training_time or early_stopping
670
+ while not do_stop:
671
+ is_checkpoint = 0
672
+ steps += 1
673
+ epoch = steps / trn_steps
674
+
675
+ stop_accumulating_grads = False
676
+ accumulated_steps = 0
677
+ if not with_dp:
678
+ optimizer.zero_grad(set_to_none=True)
679
+ while not stop_accumulating_grads:
680
+ # fetch next training (micro)batch
681
+ try:
682
+ step_data = next(trn_data_iter)
683
+ except StopIteration:
684
+ trn_data_iter = iter(trn_dataloader)
685
+ step_data = next(trn_data_iter)
686
+ # forward pass + calculate sample losses
687
+ step_losses = _calculate_sample_losses(argn, step_data)
688
+ # FIXME in sequential case, this is an approximation, it should be divided by total sum of masks in the
689
+ # entire batch to get the average loss per sample. Less importantly the final sample may be smaller
690
+ # than the batch size in both flat and sequential case.
691
+ # calculate total step loss
692
+ step_loss = torch.mean(step_losses) / (1 if with_dp else gradient_accumulation_steps)
693
+ if with_dp:
694
+ # opacus handles the gradient accumulation internally
695
+ optimizer.zero_grad(set_to_none=True)
696
+ # backward pass
697
+ with warnings.catch_warnings():
698
+ warnings.filterwarnings("ignore", category=FutureWarning, message="Using a non-full backward hook*")
699
+ step_loss.backward()
700
+ accumulated_steps += 1
701
+ # explicitly count the number of processed samples as the actual batch size can vary when DP is on
702
+ samples += step_losses.shape[0]
703
+ if with_dp:
704
+ # for DP training, the optimizer will do different operations during virtual steps and logical steps
705
+ # - virtual step: clip and accumulate gradients
706
+ # - logical step: clip and accumulate gradients, add noises to gradients and update parameters
707
+ optimizer.step()
708
+ # if step was not skipped, it was a logical step, and we can stop accumulating gradients
709
+ stop_accumulating_grads = not optimizer._is_last_step_skipped
710
+ elif accumulated_steps % gradient_accumulation_steps == 0:
711
+ # update parameters with accumulated gradients
712
+ optimizer.step()
713
+ stop_accumulating_grads = True
714
+ # detach losses from the graph
715
+ step_losses = step_losses.detach()
716
+ trn_sample_losses.extend(step_losses)
717
+
718
+ current_lr = optimizer.param_groups[0][
719
+ "lr"
720
+ ] # currently assume that we have the same lr for all param groups
721
+
722
+ # only the scheduling for ReduceLROnPlateau is postponed until the metric becomes available
723
+ if not isinstance(lr_scheduler, torch.optim.lr_scheduler.ReduceLROnPlateau):
724
+ lr_scheduler.step()
725
+
726
+ # do validation
727
+ do_validation = on_epoch_end = epoch.is_integer()
728
+ if do_validation:
729
+ # calculate val loss and trn loss
730
+ val_loss = _calculate_val_loss(model=argn, val_dataloader=val_dataloader)
731
+ # handle scenario where model training ran into numeric instability
732
+ if pd.isna(val_loss):
733
+ _LOG.warning("validation loss is not available - reset model weights to last checkpoint")
734
+ load_model_weights(
735
+ model=argn,
736
+ path=workspace.model_tabular_weights_path,
737
+ device=device,
738
+ )
739
+ trn_loss = _calculate_average_trn_loss(trn_sample_losses)
740
+ dp_epsilon = privacy_engine.get_epsilon(dp_delta) if with_dp else None
741
+ has_exceeded_dp_max_epsilon = dp_epsilon > dp_max_epsilon if with_dp else False
742
+ if not has_exceeded_dp_max_epsilon:
743
+ # save model weights with the best validation loss (and that hasn't exceeded DP max epsilon)
744
+ is_checkpoint = model_checkpoint.save_checkpoint_if_best(
745
+ val_loss=val_loss,
746
+ model=argn,
747
+ optimizer=optimizer,
748
+ lr_scheduler=lr_scheduler,
749
+ dp_accountant=privacy_engine.accountant if with_dp else None,
750
+ )
751
+ else:
752
+ _LOG.info("early stopping: current DP epsilon has exceeded max epsilon")
753
+ # gather message for progress with checkpoint info
754
+ progress_message = ProgressMessage(
755
+ epoch=epoch,
756
+ is_checkpoint=is_checkpoint,
757
+ steps=steps,
758
+ samples=samples,
759
+ trn_loss=trn_loss,
760
+ val_loss=val_loss,
761
+ total_time=total_time_init + time.time() - start_trn_time,
762
+ learn_rate=current_lr,
763
+ dp_eps=dp_epsilon,
764
+ dp_delta=dp_delta,
765
+ )
766
+ # check for early stopping
767
+ do_stop = early_stopper(val_loss=val_loss) or has_exceeded_dp_max_epsilon
768
+ # scheduling for ReduceLROnPlateau
769
+ if isinstance(lr_scheduler, torch.optim.lr_scheduler.ReduceLROnPlateau):
770
+ lr_scheduler.step(metrics=val_loss)
771
+
772
+ # log progress, either by time or by steps, whatever is shorter
773
+ elapsed_training_time = time.time() - start_trn_time
774
+ estimated_time_for_max_epochs = (max_epochs * trn_steps) * (elapsed_training_time / steps)
775
+ if max_training_time < estimated_time_for_max_epochs:
776
+ # use seconds for measuring progress against max_training_time
777
+ progress_total_count = max_training_time
778
+ progress_processed = elapsed_training_time
779
+ else:
780
+ # use steps for measuring progress against max_epochs
781
+ progress_total_count = max_epochs * trn_steps
782
+ progress_processed = steps
783
+ # send a progress message at least every X minutes
784
+ last_msg_interval = 5 * 60
785
+ last_msg_elapsed = time.time() - last_msg_time
786
+ if progress_message is None and (last_msg_elapsed > last_msg_interval or steps == 1):
787
+ # running mean loss of the most recent training samples
788
+ running_trn_loss = _calculate_average_trn_loss(trn_sample_losses, n=val_steps * val_batch_size)
789
+ dp_epsilon = privacy_engine.get_epsilon(dp_delta) if with_dp else None
790
+ progress_message = ProgressMessage(
791
+ epoch=epoch,
792
+ is_checkpoint=is_checkpoint,
793
+ steps=steps,
794
+ samples=samples,
795
+ trn_loss=running_trn_loss,
796
+ val_loss=None,
797
+ total_time=total_time_init + time.time() - start_trn_time,
798
+ learn_rate=current_lr,
799
+ dp_eps=dp_epsilon,
800
+ dp_delta=dp_delta,
801
+ )
802
+ if progress_message:
803
+ last_msg_time = time.time()
804
+ # send progress update
805
+ res = progress.update(
806
+ completed=int(progress_processed),
807
+ total=int(progress_total_count),
808
+ message=progress_message,
809
+ )
810
+ if do_validation:
811
+ upload_model_data_callback()
812
+ progress_message = None
813
+ if (res or {}).get("stopExecution", False):
814
+ _LOG.info("received STOP EXECUTION signal")
815
+ do_stop = True
816
+
817
+ if on_epoch_end:
818
+ trn_sample_losses = []
819
+
820
+ # check for max_epochs
821
+ if epoch > max_epochs:
822
+ do_stop = True
823
+
824
+ # check for max_training_time
825
+ if (total_time_init + time.time() - start_trn_time) > max_training_time:
826
+ do_stop = True
827
+
828
+ # no checkpoint is saved yet because the training stopped before the first epoch ended
829
+ if not model_checkpoint.has_saved_once():
830
+ _LOG.info("saving model weights, as none were saved so far")
831
+ model_checkpoint.save_checkpoint(
832
+ model=argn,
833
+ optimizer=optimizer,
834
+ lr_scheduler=lr_scheduler,
835
+ dp_accountant=privacy_engine.accountant if with_dp else None,
836
+ )
837
+ val_loss = _calculate_val_loss(model=argn, val_dataloader=val_dataloader)
838
+ dp_epsilon = privacy_engine.get_epsilon(dp_delta) if with_dp else None
839
+ # send a final message to inform how far we've progressed
840
+ trn_loss = _calculate_average_trn_loss(trn_sample_losses)
841
+ progress_message = ProgressMessage(
842
+ epoch=epoch,
843
+ is_checkpoint=1,
844
+ steps=steps,
845
+ samples=samples,
846
+ trn_loss=trn_loss,
847
+ val_loss=val_loss,
848
+ total_time=total_time_init + time.time() - start_trn_time,
849
+ learn_rate=current_lr,
850
+ dp_eps=dp_epsilon,
851
+ dp_delta=dp_delta,
852
+ )
853
+ progress.update(completed=steps, total=steps, message=progress_message)
854
+ # ensure everything gets uploaded
855
+ upload_model_data_callback()
856
+ _LOG.info(f"TRAIN_TABULAR finished in {time.time() - t0:.2f}s")