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,548 @@
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
+ """
16
+ Numeric encoding can be done in 3 distinct ways: numeric_discrete, numeric_binned or numeric_digit. Discrete treats
17
+ the numeric values basically as a categorical, that doesn't generate RARE tokens. Binned assigns each value to one of
18
+ the intervals within the valid value range, and then randomly draws from it during decoding. And digit splits the values
19
+ into separate sub columns, one for each digit position. In contrast to digit, discrete and binned use only one sub
20
+ column, and with that is significantly less compute intensive.
21
+
22
+ The encoding type numeric_auto allows the selection of the numeric encoding type to be based on a heuristic after the
23
+ analysis phase is done.
24
+ """
25
+
26
+ import typing
27
+
28
+ import numpy as np
29
+ import pandas as pd
30
+
31
+ from mostlyai.engine._common import find_distinct_bins, safe_convert_numeric
32
+ from mostlyai.engine._dtypes import is_float_dtype, is_integer_dtype
33
+ from mostlyai.engine._encoding_types.tabular.categorical import (
34
+ CATEGORICAL_NULL_TOKEN,
35
+ CATEGORICAL_SUB_COL_SUFFIX,
36
+ CATEGORICAL_UNKNOWN_TOKEN,
37
+ decode_categorical,
38
+ encode_categorical,
39
+ )
40
+ from mostlyai.engine.domain import ModelEncodingType
41
+
42
+ # do not use numeric_discrete if we have too many distinct values
43
+ NUMERIC_AUTO_DISCRETE_MAX_VALUES = 100
44
+ # do not use numeric_discrete if we have too many rare values
45
+ NUMERIC_AUTO_DISCRETE_MIN_NON_RARE = 0.999
46
+ # do not use numeric digit if we have too many digit positions
47
+ NUMERIC_AUTO_DIGIT_MAX_DIGITS = 3
48
+
49
+ # max number of bins to use for binning
50
+ NUMERIC_BINNED_MAX_BINS = 100
51
+ # max number of quantiles to be calculated for each partition
52
+ NUMERIC_BINNED_MAX_QUANTILES = 1001
53
+ # suffix for numeric binned sub-column
54
+ NUMERIC_BINNED_SUB_COL_SUFFIX = "bin"
55
+ # special tokens for numeric binned encoding
56
+ NUMERIC_BINNED_UNKNOWN_TOKEN = "<<UNK>>"
57
+ NUMERIC_BINNED_NULL_TOKEN = "<<NULL>>"
58
+ NUMERIC_BINNED_MIN_TOKEN = "<<MIN>>"
59
+ NUMERIC_BINNED_MAX_TOKEN = "<<MAX>>"
60
+
61
+ # suffix for numeric discrete sub-column
62
+ NUMERIC_DISCRETE_SUB_COL_SUFFIX = CATEGORICAL_SUB_COL_SUFFIX
63
+ # special tokens for numeric discrete encoding
64
+ NUMERIC_DISCRETE_UNKNOWN_TOKEN = CATEGORICAL_UNKNOWN_TOKEN
65
+ NUMERIC_DISCRETE_NULL_TOKEN = CATEGORICAL_NULL_TOKEN
66
+
67
+ # maximum and minimum precision that is being considered
68
+ NUMERIC_DIGIT_MAX_DECIMAL = 18
69
+ NUMERIC_DIGIT_MIN_DECIMAL = -8
70
+
71
+
72
+ def _type_safe_numeric_series(numeric_array: np.ndarray | list, pd_dtype: str) -> pd.Series:
73
+ # make a safe conversion using numpy's astype as an intermediary
74
+ # and then pandas type to match values to pd_dtype
75
+ np_dtype = int if pd_dtype == "Int64" else float
76
+ i_min = np.iinfo(int).min
77
+ i_max = np.iinfo(int).max
78
+
79
+ def _clip_int(vals):
80
+ # clip the array values to keep within representable boundaries in signed int representation
81
+ return [min(max(int(v), i_min), i_max) for v in vals]
82
+
83
+ if isinstance(numeric_array, list):
84
+ if np_dtype is int:
85
+ numeric_array = _clip_int(numeric_array)
86
+ numeric_array = np.array(numeric_array)
87
+
88
+ elif np_dtype is int:
89
+ try:
90
+ numeric_array.astype(int, casting="safe")
91
+ except TypeError:
92
+ # if it cannot be casted safely (e.g. without integer overflow)
93
+ numeric_array = np.array(_clip_int(numeric_array))
94
+
95
+ return pd.Series(np.array([v for v in numeric_array]).astype(np_dtype), dtype=pd_dtype)
96
+
97
+
98
+ def split_sub_columns_digit(
99
+ values: pd.Series,
100
+ max_decimal=NUMERIC_DIGIT_MAX_DECIMAL,
101
+ min_decimal=NUMERIC_DIGIT_MIN_DECIMAL,
102
+ ) -> pd.DataFrame:
103
+ if not is_integer_dtype(values) and not is_float_dtype(values):
104
+ raise ValueError("expected to be numeric")
105
+
106
+ columns = [f"E{i}" for i in np.arange(max_decimal, min_decimal - 1, -1)]
107
+ if values.isna().all():
108
+ # handle special case when all values are nan
109
+ df = pd.DataFrame({c: [0] * len(values) for c in columns})
110
+ else:
111
+ # convert to float64 as `np.format_float_positional` doesn't support Float64
112
+ values = values.astype("float64")
113
+ # rely on `np.format_float_positional` to determine string representation of absolute values
114
+ values_str = (
115
+ values.abs()
116
+ .apply(lambda x: np.format_float_positional(x, unique=True, pad_left=50, pad_right=20, precision=20))
117
+ # convert to string[pyarrow] for faster processing
118
+ .astype("string[pyarrow]")
119
+ # replace nan with pd.NA for faster processing
120
+ .replace("nan", pd.NA)
121
+ )
122
+ values_str = values_str.str.replace(" ", "0")
123
+ values_str = values_str.str.replace(".", "", n=1, regex=False)
124
+ values_str = values_str.str[(49 - max_decimal) : (49 - min_decimal + 1)]
125
+ df = values_str.str.split("", n=max_decimal - min_decimal + 2, expand=True)
126
+ df = df.drop(columns=[0, max_decimal - min_decimal + 2])
127
+ df = df.fillna("0")
128
+ df.columns = columns
129
+ df.insert(0, "nan", values.isna())
130
+ df.insert(1, "neg", (~values.isna()) & (values < 0))
131
+ df = df.astype("int")
132
+ return df
133
+
134
+
135
+ def analyze_numeric(
136
+ values: pd.Series,
137
+ root_keys: pd.Series,
138
+ _: pd.Series | None = None,
139
+ encoding_type: ModelEncodingType = ModelEncodingType.tabular_numeric_auto,
140
+ ) -> dict:
141
+ values = safe_convert_numeric(values)
142
+ non_na_values = values.dropna()
143
+ cnt_unique_values = non_na_values.nunique()
144
+
145
+ # determine sufficient quantiles; used for binned numeric encoding
146
+ if (
147
+ encoding_type in [ModelEncodingType.tabular_numeric_binned, ModelEncodingType.tabular_numeric_auto]
148
+ and len(non_na_values) > 0
149
+ ):
150
+ quantiles = np.quantile(
151
+ values.dropna(),
152
+ np.linspace(0, 1, NUMERIC_BINNED_MAX_QUANTILES),
153
+ method="closest_observation",
154
+ ).tolist()
155
+ else:
156
+ quantiles = None
157
+
158
+ # for each unique value count distinct root_keys; used for discrete numeric encoding
159
+ if encoding_type == ModelEncodingType.tabular_numeric_discrete or (
160
+ encoding_type == ModelEncodingType.tabular_numeric_auto and cnt_unique_values < NUMERIC_AUTO_DISCRETE_MAX_VALUES
161
+ ):
162
+ df = pd.concat([root_keys, values], axis=1)
163
+ cnt_values = df.groupby(values.name)[root_keys.name].nunique().to_dict()
164
+ else:
165
+ # do not count values, if there are too many
166
+ cnt_values = None
167
+
168
+ # determine lowest/highest values by root ID, and return Top 10
169
+ df = pd.concat([root_keys, values], axis=1)
170
+ min_values = df.groupby(root_keys.name)[values.name].min().dropna()
171
+ min11 = min_values.sort_values(ascending=True).head(11).astype("float").tolist()
172
+ max_values = df.groupby(root_keys.name)[values.name].max().dropna()
173
+ max11 = max_values.sort_values(ascending=False).head(11).astype("float").tolist()
174
+
175
+ # split values into digits; used for digit numeric encoding, plus to determine precision
176
+ df_split = split_sub_columns_digit(values)
177
+ is_not_nan = df_split["nan"] == 0
178
+ has_nan = sum(df_split["nan"]) > 0
179
+ has_neg = sum(df_split["neg"]) > 0
180
+
181
+ # extract min/max digit for each position to determine valid value range for digit encoding
182
+ if any(is_not_nan):
183
+ min_digits = {k: int(df_split[k][is_not_nan].min()) for k in df_split if k.startswith("E")}
184
+ max_digits = {k: int(df_split[k][is_not_nan].max()) for k in df_split if k.startswith("E")}
185
+ else:
186
+ min_digits = {k: 0 for k in df_split if k.startswith("E")}
187
+ max_digits = {k: 0 for k in df_split if k.startswith("E")}
188
+
189
+ # return stats
190
+ stats = {
191
+ "has_nan": has_nan,
192
+ "has_neg": has_neg,
193
+ "min_digits": min_digits,
194
+ "max_digits": max_digits,
195
+ "min11": min11,
196
+ "max11": max11,
197
+ "cnt_values": cnt_values,
198
+ "quantiles": quantiles,
199
+ }
200
+ return stats
201
+
202
+
203
+ def analyze_reduce_numeric(
204
+ stats_list: list[dict],
205
+ value_protection: bool = True,
206
+ encoding_type: ModelEncodingType | None = ModelEncodingType.tabular_numeric_auto,
207
+ ) -> dict:
208
+ # check for occurrence of NaN values
209
+ has_nan = any([j["has_nan"] for j in stats_list])
210
+ # check if there are negative values
211
+ has_neg = any([j["has_neg"] for j in stats_list])
212
+
213
+ # determine precision to apply rounding of sampled values during generation
214
+ keys = stats_list[0]["max_digits"].keys()
215
+ min_digits = {k: min([j["min_digits"][k] for j in stats_list]) for k in keys}
216
+ max_digits = {k: max([j["max_digits"][k] for j in stats_list]) for k in keys}
217
+ non_zero_prec = [k for k in keys if max_digits[k] > 0 and k.startswith("E")]
218
+ min_decimal = min([int(k[1:]) for k in non_zero_prec]) if len(non_zero_prec) > 0 else 0
219
+
220
+ # determine min / max 5 values to map too low / too high values to
221
+ min11 = sorted([v for min11 in [j["min11"] for j in stats_list] for v in min11], reverse=False)[:11]
222
+ max11 = sorted([v for max11 in [j["max11"] for j in stats_list] for v in max11], reverse=True)[:11]
223
+ if value_protection:
224
+ # extreme value protection - discard lowest/highest 5 values
225
+ if len(min11) < 11 or len(max11) < 11:
226
+ # less than 11 subjects with non-NULL values; we need to protect all
227
+ min5 = []
228
+ max5 = []
229
+ else:
230
+ min5 = min11[5:10] # drop 1 to 5th lowest; keep 6th to 10th lowest
231
+ max5 = max11[5:10] # drop 1 to 5th highest; keep 6th to 10th highest
232
+ else:
233
+ min5 = min11[0:5]
234
+ max5 = max11[0:5]
235
+
236
+ if len(min5) > 0 or len(max5) > 0:
237
+ max_abs = np.max(np.abs(np.array([min5[0], max5[0]])))
238
+ max_decimal = int(np.floor(np.log10(max_abs))) if max_abs >= 10 else 0
239
+ else:
240
+ max_decimal = 0
241
+ # don't allow more digits than the capped value for it
242
+ decimal_cap = [d[1:] for d in keys][0]
243
+ decimal_cap = int(decimal_cap) if decimal_cap.isnumeric() else NUMERIC_DIGIT_MAX_DECIMAL
244
+ max_decimal = min(max(min_decimal, max_decimal), decimal_cap)
245
+
246
+ # sum up cnt_values (if they exist for all partitions)
247
+ has_cnt_values = all([j["cnt_values"] for j in stats_list])
248
+ if has_cnt_values:
249
+ # sum up all counts for each categorical value
250
+ cnt_values: dict[str, int] = {}
251
+ for item in stats_list:
252
+ for value, count in item["cnt_values"].items():
253
+ cnt_values[value] = cnt_values.get(value, 0) + count
254
+ cnt_total = sum(cnt_values.values())
255
+ # apply rare value protection
256
+ if value_protection:
257
+ rare_min = 5 + int(3 * np.random.uniform())
258
+ cnt_values = {c: v for c, v in cnt_values.items() if v > rare_min}
259
+ non_rare_ratio = sum(cnt_values.values()) / cnt_total
260
+ else:
261
+ cnt_values = {}
262
+ non_rare_ratio = 0
263
+
264
+ # auto heuristic
265
+ if encoding_type == ModelEncodingType.tabular_numeric_auto:
266
+ if non_rare_ratio > NUMERIC_AUTO_DISCRETE_MIN_NON_RARE:
267
+ encoding_type = ModelEncodingType.tabular_numeric_discrete
268
+ elif len(non_zero_prec) <= NUMERIC_AUTO_DIGIT_MAX_DIGITS:
269
+ encoding_type = ModelEncodingType.tabular_numeric_digit
270
+ else:
271
+ encoding_type = ModelEncodingType.tabular_numeric_binned
272
+
273
+ if encoding_type == ModelEncodingType.tabular_numeric_discrete:
274
+ # add unknown/rare token
275
+ categories = [NUMERIC_DISCRETE_UNKNOWN_TOKEN]
276
+ # add NULL token if NaN values exist
277
+ if has_nan:
278
+ categories += [NUMERIC_DISCRETE_NULL_TOKEN]
279
+ categories += [k for k in cnt_values.keys()]
280
+ stats = {
281
+ "encoding_type": ModelEncodingType.tabular_numeric_discrete.value,
282
+ "cardinalities": {CATEGORICAL_SUB_COL_SUFFIX: len(categories)},
283
+ "codes": {categories[i]: i for i in range(len(categories))},
284
+ "min_decimal": min_decimal,
285
+ }
286
+
287
+ elif encoding_type == ModelEncodingType.tabular_numeric_digit:
288
+ cardinalities = {}
289
+ if has_nan:
290
+ cardinalities["nan"] = 2 # binary
291
+ if has_neg:
292
+ cardinalities["neg"] = 2 # binary
293
+ for d in np.arange(max_decimal, min_decimal - 1, -1):
294
+ # each digit will be encoded to `[0, max_digit-min_digit]`, thus the cardinality is `max_digit+1-min_digit`;
295
+ cardinalities[f"E{d}"] = max_digits[f"E{d}"] + 1 - min_digits[f"E{d}"]
296
+ stats = {
297
+ "encoding_type": ModelEncodingType.tabular_numeric_digit.value,
298
+ "cardinalities": cardinalities,
299
+ "has_nan": has_nan,
300
+ "has_neg": has_neg,
301
+ "min_digits": min_digits,
302
+ "max_digits": max_digits,
303
+ "max_decimal": max_decimal,
304
+ "min_decimal": min_decimal,
305
+ "min5": min5,
306
+ "max5": max5,
307
+ }
308
+
309
+ elif encoding_type == ModelEncodingType.tabular_numeric_binned:
310
+ # binned numeric encoding
311
+ quantiles = np.concatenate([j["quantiles"] for j in stats_list if j["quantiles"]])
312
+ if len(min5) == 0 or len(max5) == 0:
313
+ # handle edge case where all values are privacy protected
314
+ bins = [0]
315
+ min_decimal = 0
316
+ else:
317
+ quantiles = list(np.clip(quantiles, min(min5), max(max5)))
318
+ bins = find_distinct_bins(quantiles, NUMERIC_BINNED_MAX_BINS)
319
+ # add unknown/rare token
320
+ categories = [NUMERIC_BINNED_UNKNOWN_TOKEN]
321
+ # add NULL token if NaN values exist
322
+ if has_nan:
323
+ categories += [NUMERIC_BINNED_NULL_TOKEN]
324
+
325
+ # add min/max tokens if min/max are not rare
326
+ if len(set(min5)) == 1:
327
+ categories += [NUMERIC_BINNED_MIN_TOKEN]
328
+ if len(set(max5)) == 1:
329
+ categories += [NUMERIC_BINNED_MAX_TOKEN]
330
+ stats = {
331
+ "encoding_type": ModelEncodingType.tabular_numeric_binned.value,
332
+ "cardinalities": {NUMERIC_BINNED_SUB_COL_SUFFIX: len(categories) + len(bins) - 1},
333
+ "codes": {categories[i]: i for i in range(len(categories))},
334
+ "min_decimal": min_decimal,
335
+ "bins": bins,
336
+ }
337
+ else:
338
+ raise ValueError(f"Unknown encoding type {encoding_type}")
339
+ return stats
340
+
341
+
342
+ def encode_numeric(values: pd.Series, stats: dict, _: pd.Series | None = None) -> pd.DataFrame:
343
+ if stats["encoding_type"] == ModelEncodingType.tabular_numeric_discrete:
344
+ df = _encode_numeric_discrete(values, stats)
345
+ elif stats["encoding_type"] == ModelEncodingType.tabular_numeric_digit:
346
+ df = _encode_numeric_digit(values, stats)
347
+ elif stats["encoding_type"] == ModelEncodingType.tabular_numeric_binned:
348
+ df = _encode_numeric_binned(values, stats)
349
+ else:
350
+ raise ValueError(f"Unknown encoding type {stats['encoding_type']}")
351
+ return df
352
+
353
+
354
+ def _encode_numeric_discrete(values: pd.Series, stats: dict, _: pd.Series | None = None) -> pd.DataFrame:
355
+ values = safe_convert_numeric(values)
356
+ df = encode_categorical(values, stats)
357
+ return df
358
+
359
+
360
+ def _encode_numeric_digit(values: pd.Series, stats: dict, _: pd.Series | None = None) -> pd.DataFrame:
361
+ values = safe_convert_numeric(values)
362
+ # try to convert to int, if possible
363
+ dtype = "Int64" if stats["min_decimal"] == 0 else "Float64"
364
+ if dtype == "Int64":
365
+ values = values.round()
366
+ try:
367
+ values = values.astype(dtype)
368
+ except TypeError:
369
+ if dtype == "Int64": # if couldn't safely convert to int, stick to float
370
+ dtype = "Float64"
371
+ values = values.astype(dtype)
372
+ # reset index, as `values.mask` can throw errors for misaligned indices
373
+ values.reset_index(drop=True, inplace=True)
374
+ # replace extreme values with randomly sampled 5-th to 10-th largest/smallest values
375
+ min5 = _type_safe_numeric_series(stats["min5"] or [0], dtype)
376
+ max5 = _type_safe_numeric_series(stats["max5"] or [0], dtype)
377
+ values.mask(
378
+ values < min5[0],
379
+ min5.sample(n=len(values), replace=True, ignore_index=True),
380
+ inplace=True,
381
+ )
382
+ values.mask(
383
+ values > max5[0],
384
+ max5.sample(n=len(values), replace=True, ignore_index=True),
385
+ inplace=True,
386
+ )
387
+ # split to sub_columns
388
+ df = split_sub_columns_digit(values, stats["max_decimal"], stats["min_decimal"])
389
+ is_not_nan = df["nan"] == 0
390
+ # normalize values to `[0, max_digit-min_digit]`
391
+ for d in np.arange(stats["max_decimal"], stats["min_decimal"] - 1, -1):
392
+ key = f"E{d}"
393
+ # subtract minimum value
394
+ df[key] = df[key].where(~is_not_nan, df[key] - stats["min_digits"][key])
395
+ # ensure that any value is mapped onto valid value range
396
+ df[key] = np.minimum(df[key], stats["max_digits"][key] - stats["min_digits"][key])
397
+ df[key] = np.maximum(df[key], 0)
398
+
399
+ # ensure that encoded digits are mapped onto valid value range
400
+ for d in np.arange(stats["max_decimal"], stats["min_decimal"] - 1, -1):
401
+ df[f"E{d}"] = np.minimum(df[f"E{d}"], stats["max_digits"][f"E{d}"])
402
+ if not stats["has_nan"]:
403
+ df.drop("nan", inplace=True, axis=1)
404
+ if not stats["has_neg"]:
405
+ df.drop("neg", inplace=True, axis=1)
406
+ return df
407
+
408
+
409
+ def _encode_numeric_binned(values: pd.Series, stats: dict, _: pd.Series | None = None) -> pd.DataFrame:
410
+ values = safe_convert_numeric(values)
411
+ bins = stats["bins"].copy()
412
+ min_value = bins[0]
413
+ max_value = bins[-1]
414
+ # we expand first bin edge to -inf, and last bin edge to +inf, to ensure that any value that is too low or too
415
+ # high will be mapped to first or last bin, respectively
416
+ bins[0] = -np.inf
417
+ bins[-1] = np.inf
418
+ codes = pd.Series(
419
+ pd.cut(values, bins=bins, right=False).cat.codes,
420
+ name=CATEGORICAL_SUB_COL_SUFFIX,
421
+ index=values.index,
422
+ )
423
+ # offset codes by number of special tokens
424
+ codes_bin_offset = len(stats["codes"])
425
+ codes = codes + codes_bin_offset
426
+ # explicitly encode NA values
427
+ if NUMERIC_BINNED_NULL_TOKEN in stats["codes"]:
428
+ codes.mask(values.isna(), stats["codes"][NUMERIC_BINNED_NULL_TOKEN], inplace=True)
429
+ else:
430
+ codes.mask(values.isna(), stats["codes"][NUMERIC_BINNED_UNKNOWN_TOKEN], inplace=True)
431
+ # explicitly encode min/max values
432
+ if NUMERIC_BINNED_MIN_TOKEN in stats["codes"]:
433
+ codes.mask(values == min_value, stats["codes"][NUMERIC_BINNED_MIN_TOKEN], inplace=True)
434
+ if NUMERIC_BINNED_MAX_TOKEN in stats["codes"]:
435
+ codes.mask(values == max_value, stats["codes"][NUMERIC_BINNED_MAX_TOKEN], inplace=True)
436
+ df = pd.DataFrame({NUMERIC_BINNED_SUB_COL_SUFFIX: codes})
437
+ return df
438
+
439
+
440
+ def decode_numeric(df_encoded: pd.DataFrame, stats: dict, _: pd.Series | None = None) -> pd.Series:
441
+ if stats["encoding_type"] == ModelEncodingType.tabular_numeric_discrete:
442
+ values = _decode_numeric_discrete(df_encoded, stats)
443
+ elif stats["encoding_type"] == ModelEncodingType.tabular_numeric_digit:
444
+ values = _decode_numeric_digit(df_encoded, stats)
445
+ elif stats["encoding_type"] == ModelEncodingType.tabular_numeric_binned:
446
+ values = _decode_numeric_binned(df_encoded, stats)
447
+ else:
448
+ raise ValueError(f"Unknown encoding type {stats['encoding_type']}")
449
+ return values
450
+
451
+
452
+ def _decode_numeric_discrete(df_encoded: pd.DataFrame, stats: dict) -> pd.Series:
453
+ # determine output dtype
454
+ dtype = "Float64" if stats["min_decimal"] < 0 else "Int64"
455
+
456
+ values = decode_categorical(df_encoded, stats)
457
+ # replace any unknown values; either with NAs or with random values
458
+ cnt_unk = (values == NUMERIC_DISCRETE_UNKNOWN_TOKEN).sum()
459
+ if cnt_unk > 0:
460
+ if NUMERIC_DISCRETE_NULL_TOKEN in stats["codes"]:
461
+ values.mask(values == NUMERIC_DISCRETE_UNKNOWN_TOKEN, pd.NA, inplace=True)
462
+ else:
463
+ valid_values = pd.Series(
464
+ [
465
+ str(k)
466
+ for k in stats["codes"].keys()
467
+ if k not in [NUMERIC_DISCRETE_UNKNOWN_TOKEN, NUMERIC_DISCRETE_NULL_TOKEN]
468
+ ]
469
+ )
470
+ if valid_values.empty: # for the edge-case of "all values are rare"
471
+ valid_values = pd.Series([pd.NA])
472
+ values.mask(
473
+ values == NUMERIC_DISCRETE_UNKNOWN_TOKEN,
474
+ valid_values.sample(cnt_unk, replace=True, ignore_index=True),
475
+ inplace=True,
476
+ )
477
+ values = safe_convert_numeric(values)
478
+ values = values.astype(dtype)
479
+ return values
480
+
481
+
482
+ def _decode_numeric_binned(df_encoded: pd.DataFrame, stats: dict) -> pd.Series:
483
+ # determine output dtype
484
+ dtype = "Float64" if stats["min_decimal"] < 0 else "Int64"
485
+
486
+ codes = df_encoded[NUMERIC_BINNED_SUB_COL_SUFFIX]
487
+ bins = stats["bins"]
488
+ # create empty series with N/As of same length as of df_encoded
489
+ values = pd.Series(pd.NA, index=df_encoded.index, dtype="Float64")
490
+ # map special tokens
491
+ if NUMERIC_BINNED_NULL_TOKEN in stats["codes"]:
492
+ values.mask(codes == stats["codes"][NUMERIC_BINNED_NULL_TOKEN], pd.NA, inplace=True)
493
+ if NUMERIC_BINNED_MIN_TOKEN in stats["codes"]:
494
+ values.mask(codes == stats["codes"][NUMERIC_BINNED_MIN_TOKEN], bins[0], inplace=True)
495
+ if NUMERIC_BINNED_MAX_TOKEN in stats["codes"]:
496
+ values.mask(codes == stats["codes"][NUMERIC_BINNED_MAX_TOKEN], bins[-1], inplace=True)
497
+ # map bin tokens to bin intervals
498
+ for i in range(len(bins) - 1):
499
+ # find all rows that have the current bin code
500
+ codes_bin_offset = len(stats["codes"])
501
+ idx = codes[codes == i + codes_bin_offset].index
502
+ # randomly sample from bin interval
503
+ rnd_draws = np.random.uniform(bins[i], bins[i + 1], len(idx))
504
+ # truncate to min_decimal precision
505
+ scaler = 10 ** -stats["min_decimal"]
506
+ rnd_draws = np.floor(rnd_draws * scaler) / scaler
507
+ # replace values with random draws
508
+ values.loc[idx] = rnd_draws
509
+
510
+ values = values.astype(dtype)
511
+ return values
512
+
513
+
514
+ @typing.no_type_check
515
+ def _decode_numeric_digit(df_encoded: pd.DataFrame, stats: dict) -> pd.Series:
516
+ max_decimal = stats["max_decimal"]
517
+ min_decimal = stats["min_decimal"]
518
+ # sum up all digits positions
519
+ values = [
520
+ (df_encoded[f"E{d}"] + stats["min_digits"][f"E{d}"]).to_numpy("uint64") * 10 ** int(d)
521
+ for d in np.arange(max_decimal, min_decimal - 1, -1)
522
+ ]
523
+ values = sum(values)
524
+ # convert to float if necessary
525
+ dtype = "Float64" if min_decimal < 0 else "Int64"
526
+ values = _type_safe_numeric_series(values, dtype)
527
+ if "nan" in df_encoded.columns:
528
+ values[df_encoded["nan"] == 1] = pd.NA
529
+ if "neg" in df_encoded.columns:
530
+ values[df_encoded["neg"] == 1] = -1 * values[df_encoded["neg"] == 1]
531
+ # replace extreme values with randomly sampled 5-th to 10-th largest/smallest values
532
+ if len(stats["min5"]) > 0 and len(stats["max5"]) > 0:
533
+ min5 = _type_safe_numeric_series(stats["min5"], dtype).values
534
+ max5 = _type_safe_numeric_series(stats["max5"], dtype).values
535
+ is_too_low = values.notna() & (values < min5[0])
536
+ is_too_high = values.notna() & (values > max5[0])
537
+ values.loc[is_too_low] = _type_safe_numeric_series(
538
+ np.random.uniform(min(min5), max(min5), size=sum(is_too_low)), dtype
539
+ ).values
540
+ values.loc[is_too_high] = _type_safe_numeric_series(
541
+ np.random.uniform(min(max5), max(max5), size=sum(is_too_high)), dtype
542
+ ).values
543
+ elif "nan" in df_encoded.columns:
544
+ # set all values to NaN if no valid values were present
545
+ values[df_encoded["nan"] == 0] = pd.NA
546
+ # round to min_decimal precision
547
+ values = np.round(values, -min_decimal)
548
+ return values
@@ -0,0 +1,17 @@
1
+ # Copyright 2025 MOSTLY AI
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from mostlyai.engine._language.lstm import register_mostly_lstm_model
16
+
17
+ register_mostly_lstm_model()
@@ -0,0 +1,110 @@
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
+ from typing import Any
17
+
18
+ from peft import PeftConfig, prepare_model_for_kbit_training
19
+ from transformers import AutoConfig, AutoModelForCausalLM, BitsAndBytesConfig, PreTrainedModel, PretrainedConfig
20
+ from mostlyai.engine._language.lstm import LSTMFromScratchConfig
21
+ from pathlib import Path
22
+
23
+ import torch
24
+
25
+ _LOG = logging.getLogger(__name__)
26
+
27
+ MAX_LENGTH = 10_000
28
+
29
+
30
+ def is_bf16_supported(device: torch.device) -> bool:
31
+ if device.type != "cuda":
32
+ return False
33
+ compute_capability = torch.cuda.get_device_capability(device)
34
+ return compute_capability[0] >= 8
35
+
36
+
37
+ def get_attention_implementation(config: PretrainedConfig) -> str | None:
38
+ model_cls = AutoModelForCausalLM._model_mapping[type(config)]
39
+ attn_implementation = None
40
+ if getattr(model_cls, "_supports_sdpa", False):
41
+ attn_implementation = "sdpa"
42
+ return attn_implementation
43
+
44
+
45
+ def estimate_max_tokens(tgt_stats: dict[str, Any]) -> int:
46
+ estimated_nchar = (
47
+ # accommodate leading space, curly brackets and eos
48
+ 10
49
+ # each column is roughly like '"' + col + '": "' + value + '", '
50
+ + sum([(1 + len(col) + 4 + stats["nchar_max"] + 3) for col, stats in tgt_stats["columns"].items()])
51
+ )
52
+ estimated_tokens = estimated_nchar / 2 # ~2 chars per tokens
53
+ max_tokens = int(estimated_tokens * 1.4) # add some safety buffer
54
+ return max_tokens
55
+
56
+
57
+ def load_base_model_and_config(
58
+ model_id_or_path: str | Path, device: torch.device, is_peft_adapter: bool, is_training: bool
59
+ ) -> tuple[PreTrainedModel, PretrainedConfig]:
60
+ model_id_or_path = str(model_id_or_path)
61
+ if is_peft_adapter:
62
+ # get the base model name from adapter_config.json
63
+ peft_config = PeftConfig.from_pretrained(model_id_or_path)
64
+ model_id_or_path = peft_config.base_model_name_or_path
65
+ config = AutoConfig.from_pretrained(model_id_or_path)
66
+ else:
67
+ config = AutoConfig.from_pretrained(model_id_or_path)
68
+ if config.model_type == LSTMFromScratchConfig.model_id:
69
+ # make sure that we use standard LSTM layers during inference for the model trained with DP
70
+ # (see https://opacus.ai/api/dp_rnn.html#opacus.layers.dp_rnn.DPLSTM for more details)
71
+ if not is_training:
72
+ config.with_dp = False
73
+ return AutoModelForCausalLM.from_pretrained(model_id_or_path, config=config, device_map=device), config
74
+
75
+ # Load pretrained base model
76
+ use_cache = not is_training # KV cache is not needed during training
77
+ is_gpu_training = is_training and device.type == "cuda"
78
+ bf16_supported = is_bf16_supported(device)
79
+ if bf16_supported:
80
+ attn_implementation = get_attention_implementation(config)
81
+ torch_dtype = torch.bfloat16
82
+ else:
83
+ attn_implementation = None
84
+ torch_dtype = torch.float32
85
+ if is_gpu_training:
86
+ quantization_config = BitsAndBytesConfig(
87
+ load_in_4bit=True,
88
+ bnb_4bit_quant_type="nf4",
89
+ bnb_4bit_use_double_quant=False,
90
+ bnb_4bit_compute_dtype=torch_dtype,
91
+ )
92
+ else:
93
+ quantization_config = None
94
+ model = AutoModelForCausalLM.from_pretrained(
95
+ model_id_or_path,
96
+ torch_dtype=torch_dtype,
97
+ attn_implementation=attn_implementation,
98
+ use_cache=use_cache,
99
+ device_map=device,
100
+ quantization_config=quantization_config,
101
+ )
102
+ if quantization_config:
103
+ # convert all non-kbit layers to float32
104
+ model = prepare_model_for_kbit_training(model, use_gradient_checkpointing=False)
105
+ if is_gpu_training and model.supports_gradient_checkpointing:
106
+ # pay 50% time penalty for _large_ memory savings
107
+ _LOG.info("enable gradient checkpointing")
108
+ model.gradient_checkpointing_enable()
109
+ model.enable_input_require_grads()
110
+ return model, config