mostlyai-engine 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mostlyai/engine/__init__.py +27 -0
- mostlyai/engine/_common.py +612 -0
- mostlyai/engine/_dtypes.py +58 -0
- mostlyai/engine/_encoding_types/__init__.py +13 -0
- mostlyai/engine/_encoding_types/language/__init__.py +13 -0
- mostlyai/engine/_encoding_types/language/text.py +41 -0
- mostlyai/engine/_encoding_types/tabular/__init__.py +13 -0
- mostlyai/engine/_encoding_types/tabular/categorical.py +115 -0
- mostlyai/engine/_encoding_types/tabular/character.py +155 -0
- mostlyai/engine/_encoding_types/tabular/datetime.py +254 -0
- mostlyai/engine/_encoding_types/tabular/itt.py +352 -0
- mostlyai/engine/_encoding_types/tabular/lat_long.py +353 -0
- mostlyai/engine/_encoding_types/tabular/numeric.py +548 -0
- mostlyai/engine/_language/__init__.py +17 -0
- mostlyai/engine/_language/common.py +110 -0
- mostlyai/engine/_language/encoding.py +162 -0
- mostlyai/engine/_language/engine/__init__.py +13 -0
- mostlyai/engine/_language/engine/base.py +51 -0
- mostlyai/engine/_language/engine/hf_engine.py +136 -0
- mostlyai/engine/_language/engine/vllm_engine.py +187 -0
- mostlyai/engine/_language/formatron_utils.py +114 -0
- mostlyai/engine/_language/generation.py +309 -0
- mostlyai/engine/_language/lstm.py +139 -0
- mostlyai/engine/_language/tokenizer_utils.py +144 -0
- mostlyai/engine/_language/training.py +718 -0
- mostlyai/engine/_memory.py +69 -0
- mostlyai/engine/_tabular/__init__.py +13 -0
- mostlyai/engine/_tabular/argn.py +1382 -0
- mostlyai/engine/_tabular/common.py +29 -0
- mostlyai/engine/_tabular/encoding.py +406 -0
- mostlyai/engine/_tabular/fairness.py +245 -0
- mostlyai/engine/_tabular/generation.py +1157 -0
- mostlyai/engine/_tabular/training.py +856 -0
- mostlyai/engine/_training_utils.py +159 -0
- mostlyai/engine/_workspace.py +225 -0
- mostlyai/engine/analysis.py +571 -0
- mostlyai/engine/domain.py +171 -0
- mostlyai/engine/encoding.py +42 -0
- mostlyai/engine/generation.py +110 -0
- mostlyai/engine/splitting.py +257 -0
- mostlyai/engine/training.py +104 -0
- mostlyai_engine-1.0.0.dist-info/METADATA +179 -0
- mostlyai_engine-1.0.0.dist-info/RECORD +45 -0
- mostlyai_engine-1.0.0.dist-info/WHEEL +4 -0
- mostlyai_engine-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,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
|