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,27 @@
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
+ import warnings
15
+
16
+ from mostlyai.engine.splitting import split
17
+ from mostlyai.engine.analysis import analyze
18
+ from mostlyai.engine.training import train
19
+ from mostlyai.engine.encoding import encode
20
+ from mostlyai.engine.generation import generate
21
+
22
+
23
+ __all__ = ["split", "analyze", "encode", "train", "generate"]
24
+ __version__ = "1.0.0"
25
+
26
+ # suppress specific warning related to os.fork() in multi-threaded processes
27
+ warnings.filterwarnings("ignore", category=DeprecationWarning, message=".*multi-threaded.*fork.*")
@@ -0,0 +1,612 @@
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 inspect
16
+ import itertools
17
+ import json
18
+ import logging
19
+ import platform
20
+ import time
21
+ from functools import wraps
22
+ from pathlib import Path
23
+ from typing import (
24
+ Any,
25
+ Literal,
26
+ NamedTuple,
27
+ Protocol,
28
+ )
29
+ from collections.abc import Callable, Iterable
30
+
31
+ import numpy as np
32
+ import pandas as pd
33
+ from pydantic import BaseModel
34
+
35
+
36
+ from mostlyai.engine._dtypes import is_boolean_dtype, is_float_dtype, is_integer_dtype
37
+ from mostlyai.engine.domain import ModelEncodingType
38
+
39
+
40
+ _LOG = logging.getLogger(__name__)
41
+
42
+ _LOG.info(f"running on Python ({platform.python_version()})")
43
+
44
+ TGT = "tgt"
45
+ CTXFLT = "ctxflt"
46
+ CTXSEQ = "ctxseq"
47
+ ARGN_PROCESSOR = "argn_processor"
48
+ ARGN_TABLE = "argn_table"
49
+ ARGN_COLUMN = "argn_column"
50
+ PREFIX_TABLE = ":"
51
+ PREFIX_COLUMN = "/"
52
+ PREFIX_SUB_COLUMN = "__"
53
+ SLEN_SIDX_SDEC_COLUMN = f"{TGT}{PREFIX_TABLE}{PREFIX_COLUMN}"
54
+ SLEN_SIDX_DIGIT_ENCODING_THRESHOLD = 100
55
+ SLEN_SUB_COLUMN_PREFIX = f"{SLEN_SIDX_SDEC_COLUMN}{PREFIX_SUB_COLUMN}slen_" # sequence length
56
+ SIDX_SUB_COLUMN_PREFIX = f"{SLEN_SIDX_SDEC_COLUMN}{PREFIX_SUB_COLUMN}sidx_" # sequence index
57
+ SDEC_SUB_COLUMN_PREFIX = f"{SLEN_SIDX_SDEC_COLUMN}{PREFIX_SUB_COLUMN}sdec_" # sequence index decile
58
+ TABLE_COLUMN_INFIX = "::" # this should be consistent as in mostly-data and mostlyai-qa
59
+
60
+ TEMPORARY_PRIMARY_KEY = "__primary_key"
61
+
62
+ STRING = "string[pyarrow]" # This utilizes pyarrow's large string type since pandas 2.2
63
+
64
+ # considering pandas timestamp boundaries ('1677-09-21 00:12:43.145224193' < x < '2262-04-11 23:47:16.854775807')
65
+ _MIN_DATE = np.datetime64("1700-01-01")
66
+ _MAX_DATE = np.datetime64("2250-01-01")
67
+
68
+ SubColumnsNested = dict[str, list[str]]
69
+
70
+
71
+ class ProgressCallback(Protocol):
72
+ def __call__(
73
+ self,
74
+ total: int | None = None,
75
+ completed: int | None = None,
76
+ advance: int | None = None,
77
+ message: dict | None = None,
78
+ **kwargs,
79
+ ) -> dict | None: ...
80
+
81
+
82
+ class ProgressCallbackWrapper:
83
+ def _add_to_progress_history(self, message: dict) -> None:
84
+ # convert message to DataFrame; drop all-NA columns to avoid pandas 2.x warning for concat
85
+ message_df = pd.DataFrame([message]).dropna(axis=1, how="all")
86
+ # append to history of progress messages
87
+ if self._progress_messages is None:
88
+ self._progress_messages = message_df
89
+ else:
90
+ self._progress_messages = pd.concat([self._progress_messages, message_df], ignore_index=True)
91
+ if self._progress_messages_path is not None:
92
+ self._progress_messages.to_csv(self._progress_messages_path, index=False)
93
+
94
+ def update(
95
+ self,
96
+ total: int | None = None,
97
+ completed: int | None = None,
98
+ advance: int | None = None,
99
+ message: dict | BaseModel | None = None,
100
+ **kwargs,
101
+ ) -> dict | None:
102
+ if isinstance(message, BaseModel):
103
+ message = message.model_dump(mode="json")
104
+ if message is not None:
105
+ _LOG.info(message)
106
+ self._add_to_progress_history(message)
107
+ return self._update_progress(total=total, completed=completed, advance=advance, message=message, **kwargs)
108
+
109
+ def get_last_progress_message(self) -> dict | None:
110
+ if self._progress_messages is not None:
111
+ return self._progress_messages.iloc[-1].to_dict()
112
+
113
+ def reset_progress_messages(self):
114
+ if self._progress_messages is not None:
115
+ self._progress_messages = None
116
+ if self._progress_messages_path and self._progress_messages_path.exists():
117
+ self._progress_messages_path.unlink()
118
+
119
+ def __init__(
120
+ self, update_progress: ProgressCallback | None = None, progress_messages_path: Path | None = None, **kwargs
121
+ ):
122
+ self._update_progress = update_progress if update_progress is not None else (lambda *args, **kwargs: None)
123
+ self._progress_messages_path = progress_messages_path
124
+ if self._progress_messages_path and self._progress_messages_path.exists():
125
+ self._progress_messages = pd.read_csv(self._progress_messages_path)
126
+ else:
127
+ self._progress_messages = None
128
+
129
+ def __enter__(self):
130
+ self._update_progress(completed=0, total=1)
131
+ return self
132
+
133
+ def __exit__(self, exc_type, exc_value, traceback):
134
+ if exc_type is None:
135
+ self._update_progress(completed=1, total=1)
136
+
137
+
138
+ class SubColumnLookup(NamedTuple):
139
+ col_name: str
140
+ col_idx: int # column index within a list of columns
141
+ sub_col_idx: int # index within the column it belongs to
142
+ sub_col_cum: int # cumulative index within a list of columns
143
+ sub_col_offset: int # offset of the first sub-column in the scope of the column
144
+
145
+
146
+ def cast_numpy_keys_to_python(data: Any) -> dict:
147
+ if not isinstance(data, dict):
148
+ return data
149
+
150
+ new_data = {}
151
+ for key, value in data.items():
152
+ if isinstance(key, (np.int64, np.int32)):
153
+ new_key = int(key)
154
+ else:
155
+ new_key = key
156
+ new_data[new_key] = cast_numpy_keys_to_python(value)
157
+
158
+ return new_data
159
+
160
+
161
+ def write_json(data: dict, fn: Path) -> None:
162
+ data = cast_numpy_keys_to_python(data)
163
+ fn.parent.mkdir(parents=True, exist_ok=True)
164
+ with open(fn, "w", encoding="utf-8") as outfile:
165
+ json.dump(data, outfile, ensure_ascii=False, indent=4)
166
+
167
+
168
+ def read_json(path: Path, default: dict | None = None, raises: bool | None = None) -> dict:
169
+ """
170
+ Reads JSON.
171
+
172
+ :param path: path to json
173
+ :param default: default used in case path does not exist
174
+ :param raises: if True, raises exception if path does not exist,
175
+ otherwise returns default
176
+ :return: dict representation of JSON
177
+ """
178
+
179
+ if default is None:
180
+ default = {}
181
+ if not path.exists():
182
+ if raises:
183
+ raise RuntimeError(f"File [{path}] does not exist")
184
+ else:
185
+ return default
186
+ with open(path) as json_file:
187
+ data = json.load(json_file)
188
+ return data
189
+
190
+
191
+ def is_a_list(x) -> bool:
192
+ return isinstance(x, Iterable) and not isinstance(x, str)
193
+
194
+
195
+ def is_sequential(series: pd.Series) -> bool:
196
+ return not series.empty and series.apply(is_a_list).any()
197
+
198
+
199
+ def handle_with_nested_lists(func: Callable, param_reference: str = "values"):
200
+ @wraps(func)
201
+ def wrapper(*args, **kwargs):
202
+ signature = inspect.signature(func)
203
+ bound_args = signature.bind(*args, **kwargs)
204
+ bound_args.apply_defaults()
205
+
206
+ series = bound_args.arguments.get(param_reference)
207
+
208
+ if series is not None and is_sequential(series):
209
+
210
+ def func_on_exploded_series(series):
211
+ is_empty = series.apply(lambda x: isinstance(x, Iterable) and len(x) == 0)
212
+ bound_args.arguments[param_reference] = series.explode()
213
+
214
+ result = func(*bound_args.args, **bound_args.kwargs)
215
+
216
+ result = result.groupby(level=0).apply(np.array)
217
+ result[is_empty] = result[is_empty].apply(lambda x: np.array([], dtype=x.dtype))
218
+ return result
219
+
220
+ index, series = series.index, series.reset_index(drop=True)
221
+ result = func_on_exploded_series(series).set_axis(index)
222
+ return result
223
+ else:
224
+ return func(*args, **kwargs)
225
+
226
+ return wrapper
227
+
228
+
229
+ @handle_with_nested_lists
230
+ def safe_convert_numeric(values: pd.Series, nullable_dtypes: bool = False) -> pd.Series:
231
+ if is_boolean_dtype(values):
232
+ # convert booleans to integer -> True=1, False=0
233
+ values = values.astype("Int8")
234
+ elif not is_integer_dtype(values) and not is_float_dtype(values):
235
+ # convert other non-numerics to string, and extract valid numeric sub-string
236
+ valid_num = r"(-?[0-9]*[\.]?[0-9]+(?:[eE][+\-]?\d+)?)"
237
+ values = values.astype(str).str.extract(valid_num, expand=False)
238
+ values = pd.to_numeric(values, errors="coerce")
239
+ if nullable_dtypes:
240
+ values = values.convert_dtypes()
241
+ return values
242
+
243
+
244
+ @handle_with_nested_lists
245
+ def safe_convert_datetime(values: pd.Series, date_only: bool = False) -> pd.Series:
246
+ # turn null[pyarrow] into string, can be removed once the following line is fixed in pandas:
247
+ # pd.Series([pd.NA], dtype="null[pyarrow]").mask([True], pd.NA)
248
+ # see https://github.com/pandas-dev/pandas/issues/58696 for tracking the fix of this bug
249
+ if values.dtype == "null[pyarrow]":
250
+ values = values.astype("string")
251
+ # Convert any pd.Series to datetime via `pd.to_datetime`.
252
+ values_parsed_flexible = pd.to_datetime(
253
+ values,
254
+ errors="coerce", # silently map invalid dates to NA
255
+ utc=True,
256
+ format="mixed",
257
+ dayfirst=False, # assume 1/3/2020 is Jan 3
258
+ )
259
+ values = values.mask(values_parsed_flexible.isna(), pd.NA)
260
+ values_parsed_fixed = pd.to_datetime(
261
+ values,
262
+ errors="coerce", # silently map invalid dates to NA
263
+ utc=True,
264
+ dayfirst=False, # assume 1/3/2020 is Jan 3
265
+ )
266
+ # check whether firstday=True yields less non-NA, and if so, switch to using that flag
267
+ if values_parsed_fixed.isna().sum() > values.isna().sum():
268
+ values_parsed_fixed_dayfirst = pd.to_datetime(
269
+ values,
270
+ errors="coerce", # silently map invalid dates to NA
271
+ utc=True,
272
+ format="mixed",
273
+ dayfirst=True, # assume 1/3/2020 is Mar 1
274
+ )
275
+ if values_parsed_fixed_dayfirst.isna().sum() < values_parsed_fixed.isna().sum():
276
+ values_parsed_fixed = values_parsed_fixed_dayfirst
277
+ # combine results of consistent and flexible datetime parsing, with the former having precedence
278
+ values = values_parsed_fixed.fillna(values_parsed_flexible)
279
+ if date_only:
280
+ values = pd.to_datetime(values.dt.date)
281
+ values = values.dt.tz_localize(None)
282
+ # We need to downcast from `datetime64[ns]` to `datetime64[us]`
283
+ # otherwise `pd.to_parquet` crashes for overly long precisions.
284
+ # See https://stackoverflow.com/a/56795049
285
+ values = values.astype("datetime64[us]")
286
+ return values
287
+
288
+
289
+ @handle_with_nested_lists
290
+ def safe_convert_string(values: pd.Series) -> pd.Series:
291
+ values = values.astype("string")
292
+ return values
293
+
294
+
295
+ def get_argn_name(
296
+ argn_processor: str,
297
+ argn_table: str | None = None,
298
+ argn_column: str | None = None,
299
+ argn_sub_column: str | None = None,
300
+ ) -> str:
301
+ name = [
302
+ argn_processor,
303
+ PREFIX_TABLE if any(c is not None for c in [argn_table, argn_column, argn_sub_column]) else "",
304
+ argn_table if argn_table is not None else "",
305
+ PREFIX_COLUMN if any(c is not None for c in [argn_column, argn_sub_column]) else "",
306
+ argn_column if argn_column is not None else "",
307
+ PREFIX_SUB_COLUMN if argn_sub_column is not None else "",
308
+ argn_sub_column if argn_sub_column is not None else "",
309
+ ]
310
+ return "".join(name)
311
+
312
+
313
+ def get_cardinalities(stats: dict) -> dict[str, int]:
314
+ cardinalities: dict[str, int] = {}
315
+ if stats.get("is_sequential", False):
316
+ max_seq_len = get_sequence_length_stats(stats)["max"]
317
+ cardinalities |= get_slen_sidx_sdec_cardinalities(max_seq_len)
318
+
319
+ for i, column in enumerate(stats.get("columns", [])):
320
+ column_stats = stats["columns"][column]
321
+ if "cardinalities" not in column_stats:
322
+ continue
323
+ sub_columns = {
324
+ get_argn_name(
325
+ argn_processor=column_stats[ARGN_PROCESSOR],
326
+ argn_table=column_stats[ARGN_TABLE],
327
+ argn_column=column_stats[ARGN_COLUMN],
328
+ argn_sub_column=k,
329
+ ): v
330
+ for k, v in column_stats["cardinalities"].items()
331
+ }
332
+ cardinalities = cardinalities | sub_columns
333
+ return cardinalities
334
+
335
+
336
+ def get_sub_columns_from_cardinalities(cardinalities: dict[str, int]) -> list[str]:
337
+ # eg. {'c0__E1': 10, 'c0__E0': 10, 'c1__value': 2} -> ['c0__E1', 'c0__E0', 'c1__value']
338
+ sub_columns = list(cardinalities.keys())
339
+ return sub_columns
340
+
341
+
342
+ def get_columns_from_cardinalities(cardinalities: dict[str, int]) -> list[str]:
343
+ # eg. {'c0__E1': 10, 'c0__E0': 10, 'c1__value': 2} -> ['c0', 'c1']
344
+ sub_columns = get_sub_columns_from_cardinalities(cardinalities)
345
+ columns = [col for col, _ in itertools.groupby(sub_columns, lambda x: x.split(PREFIX_SUB_COLUMN)[0])]
346
+ return columns
347
+
348
+
349
+ def get_sub_columns_nested(
350
+ sub_columns: list[str], groupby: Literal["processor", "tables", "columns"]
351
+ ) -> dict[str, list[str]]:
352
+ splitby = {
353
+ "processor": PREFIX_TABLE,
354
+ "tables": PREFIX_COLUMN,
355
+ "columns": PREFIX_SUB_COLUMN,
356
+ }[groupby]
357
+ out: dict[str, list[str]] = dict()
358
+ for sub_col in sub_columns:
359
+ key = sub_col.split(splitby)[0]
360
+ out[key] = out.get(key, []) + [sub_col]
361
+ return out
362
+
363
+
364
+ def get_sub_columns_nested_from_cardinalities(
365
+ cardinalities: dict[str, int], groupby: Literal["processor", "tables", "columns"]
366
+ ) -> SubColumnsNested:
367
+ # eg. {'c0__E1': 10, 'c0__E0': 10, 'c1__value': 2} -> {'c0': ['c0__E1', 'c0__E0'], 'c1': ['c1__value']}
368
+ sub_columns = get_sub_columns_from_cardinalities(cardinalities)
369
+ return get_sub_columns_nested(sub_columns, groupby)
370
+
371
+
372
+ def get_sub_columns_lookup(
373
+ sub_columns_nested: SubColumnsNested,
374
+ ) -> dict[str, SubColumnLookup]:
375
+ """
376
+ Create a convenient reverse lookup for each of the sub-columns
377
+ :param sub_columns_nested: must be grouped-by "columns"
378
+ :return: dict of sub_col -> SubColumnLookup items
379
+ """
380
+ sub_cols_lookup = {}
381
+ sub_col_cum_i = 0
382
+ for col_i, (name, sub_cols) in enumerate(sub_columns_nested.items()):
383
+ sub_col_offset = sub_col_cum_i
384
+ for sub_col_i, sub_col in enumerate(sub_cols):
385
+ sub_cols_lookup[sub_col] = SubColumnLookup(
386
+ col_name=name,
387
+ col_idx=col_i,
388
+ sub_col_idx=sub_col_i,
389
+ sub_col_cum=sub_col_cum_i,
390
+ sub_col_offset=sub_col_offset,
391
+ )
392
+ sub_col_cum_i += 1
393
+ return sub_cols_lookup
394
+
395
+
396
+ class CtxSequenceLengthError(Exception):
397
+ """Error raised when the cols of the same table do not have the same stats value"""
398
+
399
+
400
+ def get_ctx_sequence_length(ctx_stats: dict, key: str) -> dict[str, int]:
401
+ seq_stats: dict[str, int] = {}
402
+
403
+ for column_stats in ctx_stats.get("columns", {}).values():
404
+ if "seq_len" in column_stats:
405
+ table = get_argn_name(
406
+ argn_processor=column_stats[ARGN_PROCESSOR],
407
+ argn_table=column_stats[ARGN_TABLE],
408
+ )
409
+ cur_value = seq_stats.get(table)
410
+ if cur_value and cur_value != column_stats["seq_len"][key]:
411
+ raise CtxSequenceLengthError()
412
+ seq_stats[table] = column_stats["seq_len"][key]
413
+
414
+ return seq_stats
415
+
416
+
417
+ def get_max_data_points_per_sample(stats: dict) -> int:
418
+ """Return the maximum number of data points per sample. Either for target or for context"""
419
+ data_points = 0
420
+ seq_len_max = stats["seq_len"]["max"] if "seq_len" in stats else 1
421
+ for info in stats.get("columns", {}).values():
422
+ col_seq_len_max = info["seq_len"]["max"] if "seq_len" in info else 1
423
+ no_sub_cols = len(info["cardinalities"]) if "cardinalities" in info else 1
424
+ data_points += col_seq_len_max * no_sub_cols * seq_len_max
425
+ return data_points
426
+
427
+
428
+ def get_sequence_length_stats(stats: dict) -> dict:
429
+ if stats["is_sequential"]:
430
+ stats = {
431
+ "min": stats["seq_len"]["min"],
432
+ "median": stats["seq_len"]["median"],
433
+ "max": stats["seq_len"]["max"],
434
+ "deciles": stats["seq_len"]["deciles"],
435
+ }
436
+ else:
437
+ stats = {
438
+ "min": 1,
439
+ "median": 1,
440
+ "max": 1,
441
+ "deciles": [1 for i in range(11)],
442
+ }
443
+ return stats
444
+
445
+
446
+ def find_distinct_bins(x: list[Any], n: int, n_max: int = 1_000) -> list[Any]:
447
+ """
448
+ Find distinct bins so that `pd.cut(x, bins, include_lowest=True)` returns `n` distinct buckets with similar
449
+ number of values. For that we compute quantiles, and increase the number of quantiles until we get `n` distinct
450
+ values. If we have less distinct values than `n`, we return the distinct values.
451
+ """
452
+ # return immediately if we have less distinct values than `n`
453
+ if len(x) <= n or len(set(x)) <= n:
454
+ return list(sorted(set(x)))
455
+ no_of_quantiles = n
456
+ # increase quantiles until we have found `n` distinct bins
457
+ while no_of_quantiles <= n_max:
458
+ # calculate quantiles
459
+ qs = np.quantile(x, np.linspace(0, 1, no_of_quantiles + 1), method="closest_observation")
460
+ no_of_distinct_quantiles = len(set(qs))
461
+ # return if we have found `n` distinct quantiles
462
+ if no_of_distinct_quantiles >= n + 1:
463
+ bins = list(sorted(set(qs)))
464
+ if len(bins) > n + 1:
465
+ # handle edge case where we have more than `n` + 1 bins; this can happen if we have a eg 100 bins for
466
+ # no_of_quantiles=200, but then 102 bins for no_of_quantiles=201.
467
+ bins = bins[: (n // 2) + 1] + bins[-(n // 2) :]
468
+ return bins
469
+ # we need to increase at least by number of missing quantiles to acchieve `n` distinct quantiles
470
+ no_of_quantiles += 1 + max(0, n - no_of_distinct_quantiles)
471
+ # in case we fail to find `n` distinct bins before `n_max` we return largest set of bins
472
+ return list(sorted(set(qs)))
473
+
474
+
475
+ def apply_encoding_type_dtypes(df: pd.DataFrame, encoding_types: dict[str, ModelEncodingType]) -> pd.DataFrame:
476
+ return df.apply(lambda x: _get_type_converter(encoding_types[x.name])(x) if x.name in encoding_types else x)
477
+
478
+
479
+ def _get_type_converter(
480
+ encoding_type: ModelEncodingType | None,
481
+ ) -> Callable[[pd.Series], pd.Series]:
482
+ if encoding_type in (ModelEncodingType.tabular_categorical, ModelEncodingType.tabular_lat_long):
483
+ return safe_convert_string
484
+ elif encoding_type in (
485
+ ModelEncodingType.tabular_numeric_auto,
486
+ ModelEncodingType.tabular_numeric_digit,
487
+ ModelEncodingType.tabular_numeric_binned,
488
+ ModelEncodingType.tabular_numeric_discrete,
489
+ ):
490
+ return lambda values: safe_convert_numeric(values, nullable_dtypes=True)
491
+ elif encoding_type in (ModelEncodingType.tabular_datetime, ModelEncodingType.tabular_datetime_relative):
492
+ return safe_convert_datetime
493
+ else:
494
+ return safe_convert_string
495
+
496
+
497
+ def skip_if_error(func: Callable) -> Callable:
498
+ """
499
+ Decorator that executes the wrapped function, and gracefully absorbs any exceptions
500
+ in a case of a failure and logs the exception, accordingly.
501
+ """
502
+
503
+ @wraps(func)
504
+ def skip_if_error_wrapper(*args, **kwargs) -> Any:
505
+ try:
506
+ return func(*args, **kwargs)
507
+ except Exception as e:
508
+ _LOG.warning(f"{func.__qualname__} failed with {type(e)}: {e}")
509
+
510
+ return skip_if_error_wrapper
511
+
512
+
513
+ def encode_slen_sidx_sdec(vals: pd.Series, max_seq_len: int, prefix: str = "") -> pd.DataFrame:
514
+ assert vals.dtype == int or vals.dtype == "int64[pyarrow]"
515
+ if max_seq_len < SLEN_SIDX_DIGIT_ENCODING_THRESHOLD or prefix == SDEC_SUB_COLUMN_PREFIX:
516
+ # encode slen and sidx as numeric_discrete
517
+ df = pd.DataFrame({f"{prefix}cat": vals})
518
+ else:
519
+ # encode as numeric_digit
520
+ n_digits = len(str(max_seq_len))
521
+ df = pd.DataFrame(vals.astype(str).str.pad(width=n_digits, fillchar="0").apply(list).tolist()).astype(int)
522
+ df.columns = [f"{prefix}E{i}" for i in range(n_digits - 1, -1, -1)]
523
+ return df
524
+
525
+
526
+ def decode_slen_sidx_sdec(df_encoded: pd.DataFrame, max_seq_len: int, prefix: str = "") -> pd.Series:
527
+ if max_seq_len < SLEN_SIDX_DIGIT_ENCODING_THRESHOLD or prefix == SDEC_SUB_COLUMN_PREFIX:
528
+ # decode slen and sidx as numeric_discrete
529
+ vals = df_encoded[f"{prefix}cat"]
530
+ else:
531
+ # decode slen and sidx as numeric_digit
532
+ n_digits = len(str(max_seq_len))
533
+ vals = sum([df_encoded[f"{prefix}E{d}"] * 10 ** int(d) for d in list(range(n_digits))])
534
+ return vals
535
+
536
+
537
+ def get_slen_sidx_sdec_cardinalities(max_seq_len) -> dict[str, int]:
538
+ if max_seq_len < SLEN_SIDX_DIGIT_ENCODING_THRESHOLD:
539
+ # encode slen and sidx as numeric_discrete
540
+ slen_cardinalities = {f"{SLEN_SUB_COLUMN_PREFIX}cat": max_seq_len + 1}
541
+ sidx_cardinalities = {f"{SIDX_SUB_COLUMN_PREFIX}cat": max_seq_len + 1}
542
+ else:
543
+ # encode slen and sidx as numeric_digit
544
+ digits = [int(digit) for digit in str(max_seq_len)]
545
+ slen_cardinalities = {}
546
+ sidx_cardinalities = {}
547
+ for idx, digit in enumerate(digits):
548
+ # cap cardinality of the most significant position
549
+ # less significant positions allow any digit
550
+ card = digit + 1 if idx == 0 else 10
551
+ e_idx = len(digits) - idx - 1
552
+ slen_cardinalities[f"{SLEN_SUB_COLUMN_PREFIX}E{e_idx}"] = card
553
+ sidx_cardinalities[f"{SIDX_SUB_COLUMN_PREFIX}E{e_idx}"] = card
554
+ # order is important: slen first, then sidx, as the former has highest priority
555
+ sdec_cardinalities = {f"{SDEC_SUB_COLUMN_PREFIX}cat": 10}
556
+ return slen_cardinalities | sidx_cardinalities | sdec_cardinalities
557
+
558
+
559
+ def trim_sequences(syn: pd.DataFrame, tgt_context_key: str, seq_len_min: int, seq_len_max: int):
560
+ if syn.empty:
561
+ return syn
562
+
563
+ # use SIDX and SLEN to determine sequence length
564
+ syn[SIDX_SUB_COLUMN_PREFIX] = decode_slen_sidx_sdec(syn, seq_len_max, prefix=SIDX_SUB_COLUMN_PREFIX)
565
+ syn[SLEN_SUB_COLUMN_PREFIX] = decode_slen_sidx_sdec(syn, seq_len_max, prefix=SLEN_SUB_COLUMN_PREFIX)
566
+ # ensure that seq_len_min is respected
567
+ syn[SLEN_SUB_COLUMN_PREFIX] = np.maximum(seq_len_min, syn[SLEN_SUB_COLUMN_PREFIX])
568
+ syn = syn[syn[SIDX_SUB_COLUMN_PREFIX] < syn[SLEN_SUB_COLUMN_PREFIX]].reset_index(drop=True)
569
+ # discarded padded context rows, ie where context key has been set to None
570
+ syn = syn.dropna(subset=[tgt_context_key])
571
+ # discard SLEN and SIDX columns
572
+ syn.drop(
573
+ [c for c in syn.columns if c.startswith(SLEN_SIDX_SDEC_COLUMN)],
574
+ axis=1,
575
+ inplace=True,
576
+ )
577
+ syn.reset_index(drop=True, inplace=True)
578
+ return syn
579
+
580
+
581
+ def persist_data_part(df: pd.DataFrame, output_path: Path, infix: str):
582
+ t0 = time.time()
583
+ part_fn = f"part.{infix}.parquet"
584
+ # ensure df.shape[0] is persisted when no columns are generated by keeping index
585
+ df.to_parquet(output_path / part_fn, index=True)
586
+ _LOG.info(f"persisted {df.shape} to `{part_fn}` in {time.time() - t0:.2f}s")
587
+
588
+
589
+ class FixedSizeSampleBuffer:
590
+ def __init__(self, capacity: int):
591
+ self.capacity = capacity
592
+ self.buffer = []
593
+ self.current_size = 0
594
+ self.n_clears = 0
595
+
596
+ def add(self, tup: tuple):
597
+ assert not self.is_full()
598
+ assert len(tup) > 0 and isinstance(tup[0], Iterable)
599
+ n_samples = len(tup[0]) # assume first element holds samples
600
+ self.current_size += n_samples
601
+ self.buffer.append(tup)
602
+
603
+ def is_full(self):
604
+ return self.current_size >= self.capacity
605
+
606
+ def is_empty(self):
607
+ return len(self.buffer) == 0
608
+
609
+ def clear(self):
610
+ self.buffer = []
611
+ self.current_size = 0
612
+ self.n_clears += 1
@@ -0,0 +1,58 @@
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 pandas as pd
16
+ import pyarrow as pa
17
+
18
+
19
+ def is_string_dtype(x: pd.Series) -> bool:
20
+ if isinstance(x.dtype, pd.ArrowDtype):
21
+ return pa.types.is_string(x.dtype.pyarrow_dtype)
22
+ else:
23
+ return pd.api.types.is_string_dtype(x)
24
+
25
+
26
+ def is_integer_dtype(x: pd.Series) -> bool:
27
+ if isinstance(x.dtype, pd.ArrowDtype):
28
+ return pa.types.is_integer(x.dtype.pyarrow_dtype)
29
+ else:
30
+ return pd.api.types.is_integer_dtype(x)
31
+
32
+
33
+ def is_float_dtype(x: pd.Series) -> bool:
34
+ if isinstance(x.dtype, pd.ArrowDtype):
35
+ return pa.types.is_floating(x.dtype.pyarrow_dtype)
36
+ else:
37
+ return pd.api.types.is_float_dtype(x)
38
+
39
+
40
+ def is_date_dtype(x: pd.Series) -> bool:
41
+ if isinstance(x.dtype, pd.ArrowDtype):
42
+ return pa.types.is_date(x.dtype.pyarrow_dtype)
43
+ else:
44
+ return False
45
+
46
+
47
+ def is_timestamp_dtype(x: pd.Series) -> bool:
48
+ if isinstance(x.dtype, pd.ArrowDtype):
49
+ return pa.types.is_timestamp(x.dtype.pyarrow_dtype)
50
+ else:
51
+ return pd.api.types.is_datetime64_any_dtype(x)
52
+
53
+
54
+ def is_boolean_dtype(x: pd.Series) -> bool:
55
+ if isinstance(x.dtype, pd.ArrowDtype):
56
+ return pa.types.is_boolean(x.dtype.pyarrow_dtype)
57
+ else:
58
+ return pd.api.types.is_bool_dtype(x)