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,29 @@
|
|
|
1
|
+
# Copyright 2025 MOSTLY AI
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
import torch
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
_LOG = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_model_weights(model: torch.nn.Module, path: Path, device: torch.device):
|
|
24
|
+
try:
|
|
25
|
+
t00 = time.time()
|
|
26
|
+
model.load_state_dict(torch.load(f=path, map_location=device, weights_only=True))
|
|
27
|
+
_LOG.info(f"loaded model weights in {time.time() - t00:.2f}s")
|
|
28
|
+
except Exception as e:
|
|
29
|
+
_LOG.warning(f"failed to load model weights: {e}")
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
# Copyright 2025 MOSTLY AI
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import pandas as pd
|
|
21
|
+
from joblib import Parallel, delayed, parallel_config, cpu_count
|
|
22
|
+
|
|
23
|
+
from mostlyai.engine._common import (
|
|
24
|
+
ARGN_COLUMN,
|
|
25
|
+
ARGN_PROCESSOR,
|
|
26
|
+
ARGN_TABLE,
|
|
27
|
+
SIDX_SUB_COLUMN_PREFIX,
|
|
28
|
+
SLEN_SUB_COLUMN_PREFIX,
|
|
29
|
+
TGT,
|
|
30
|
+
encode_slen_sidx_sdec,
|
|
31
|
+
get_argn_name,
|
|
32
|
+
get_sequence_length_stats,
|
|
33
|
+
is_a_list,
|
|
34
|
+
is_sequential,
|
|
35
|
+
SDEC_SUB_COLUMN_PREFIX,
|
|
36
|
+
ProgressCallback,
|
|
37
|
+
ProgressCallbackWrapper,
|
|
38
|
+
)
|
|
39
|
+
from mostlyai.engine._encoding_types.tabular.categorical import encode_categorical
|
|
40
|
+
from mostlyai.engine._encoding_types.tabular.character import encode_character
|
|
41
|
+
from mostlyai.engine._encoding_types.tabular.datetime import encode_datetime
|
|
42
|
+
from mostlyai.engine._encoding_types.tabular.itt import encode_itt
|
|
43
|
+
from mostlyai.engine._encoding_types.tabular.lat_long import encode_latlong
|
|
44
|
+
from mostlyai.engine._encoding_types.tabular.numeric import encode_numeric
|
|
45
|
+
from mostlyai.engine.domain import ModelEncodingType
|
|
46
|
+
from mostlyai.engine._workspace import Workspace, ensure_workspace_dir, reset_dir
|
|
47
|
+
|
|
48
|
+
_LOG = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def encode(
|
|
52
|
+
workspace_dir: str | Path | None = None,
|
|
53
|
+
update_progress: ProgressCallback | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
_LOG.info("ENCODE_TABULAR started")
|
|
56
|
+
t0 = time.time()
|
|
57
|
+
with ProgressCallbackWrapper(update_progress) as progress:
|
|
58
|
+
# build paths based on workspace dir
|
|
59
|
+
workspace_dir = ensure_workspace_dir(workspace_dir)
|
|
60
|
+
workspace = Workspace(workspace_dir)
|
|
61
|
+
reset_dir(workspace.encoded_data_val.path)
|
|
62
|
+
reset_dir(workspace.encoded_data_trn.path)
|
|
63
|
+
reset_dir(workspace.encoded_data_path)
|
|
64
|
+
|
|
65
|
+
has_context = workspace.ctx_data_path.exists()
|
|
66
|
+
|
|
67
|
+
tgt_pqt_partitions = workspace.tgt_data.fetch_all()
|
|
68
|
+
if has_context:
|
|
69
|
+
ctx_pqt_partitions = workspace.ctx_data.fetch_all()
|
|
70
|
+
if len(tgt_pqt_partitions) != len(ctx_pqt_partitions):
|
|
71
|
+
raise RuntimeError("partition files for tgt and ctx do not match")
|
|
72
|
+
else:
|
|
73
|
+
ctx_pqt_partitions = []
|
|
74
|
+
tgt_stats = workspace.tgt_stats.read()
|
|
75
|
+
ctx_stats = workspace.ctx_stats.read()
|
|
76
|
+
|
|
77
|
+
for i in range(len(tgt_pqt_partitions)):
|
|
78
|
+
_encode_partition(
|
|
79
|
+
tgt_partition_file=tgt_pqt_partitions[i],
|
|
80
|
+
tgt_stats=tgt_stats,
|
|
81
|
+
output_path=workspace.encoded_data_path,
|
|
82
|
+
ctx_partition_file=ctx_pqt_partitions[i] if has_context else None,
|
|
83
|
+
ctx_stats=ctx_stats if has_context else None,
|
|
84
|
+
n_jobs=min(cpu_count() - 1, 16),
|
|
85
|
+
)
|
|
86
|
+
progress.update(completed=i, total=len(tgt_pqt_partitions) + 1)
|
|
87
|
+
_LOG.info(f"ENCODE_TABULAR finished in {time.time() - t0:.2f}s")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _encode_partition(
|
|
91
|
+
*,
|
|
92
|
+
tgt_partition_file: Path,
|
|
93
|
+
tgt_stats: dict,
|
|
94
|
+
output_path: Path,
|
|
95
|
+
ctx_partition_file: Path | None = None,
|
|
96
|
+
ctx_stats: dict | None = None,
|
|
97
|
+
n_jobs: int = 1,
|
|
98
|
+
) -> None:
|
|
99
|
+
seq_len_stats = get_sequence_length_stats(tgt_stats)
|
|
100
|
+
is_sequential = tgt_stats["is_sequential"]
|
|
101
|
+
|
|
102
|
+
tgt_context_key = tgt_stats.get("keys", {}).get("context_key")
|
|
103
|
+
ctx_primary_key = ctx_stats.get("keys", {}).get("primary_key") if ctx_stats else None
|
|
104
|
+
|
|
105
|
+
# encode target data
|
|
106
|
+
df = pd.read_parquet(tgt_partition_file)
|
|
107
|
+
df, _, tgt_context_key = encode_df(
|
|
108
|
+
df,
|
|
109
|
+
tgt_stats,
|
|
110
|
+
ctx_primary_key=None,
|
|
111
|
+
tgt_context_key=tgt_context_key,
|
|
112
|
+
n_jobs=n_jobs,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
has_context = ctx_partition_file is not None and tgt_context_key and ctx_primary_key
|
|
116
|
+
ctx_stats = ctx_stats or {}
|
|
117
|
+
if has_context:
|
|
118
|
+
# check consistency of partitioned file names
|
|
119
|
+
if ctx_partition_file and tgt_partition_file.name != ctx_partition_file.name:
|
|
120
|
+
raise RuntimeError(f"mismatch: {tgt_partition_file}!={ctx_partition_file}")
|
|
121
|
+
# encode context data
|
|
122
|
+
df_ctx = pd.read_parquet(ctx_partition_file)
|
|
123
|
+
df_ctx, ctx_primary_key, _ = encode_df(
|
|
124
|
+
df_ctx,
|
|
125
|
+
ctx_stats,
|
|
126
|
+
ctx_primary_key=ctx_primary_key,
|
|
127
|
+
tgt_context_key=None,
|
|
128
|
+
n_jobs=n_jobs,
|
|
129
|
+
)
|
|
130
|
+
# pad each list with one extra item
|
|
131
|
+
df_ctx = pad_horizontally(df_ctx, padding_value=0, right=False)
|
|
132
|
+
|
|
133
|
+
if is_sequential:
|
|
134
|
+
assert isinstance(tgt_context_key, str)
|
|
135
|
+
# trim sequences to (privacy-protected) max_len
|
|
136
|
+
max_len = seq_len_stats["max"]
|
|
137
|
+
df = df[df.groupby(tgt_context_key).cumcount() < max_len].reset_index(drop=True)
|
|
138
|
+
# enrich with sequence lengths and sequence indexes
|
|
139
|
+
df = _enrich_slen_sidx_sdec(df, tgt_context_key, max_len)
|
|
140
|
+
# flatten to list columns
|
|
141
|
+
df = flatten_frame(df, tgt_context_key)
|
|
142
|
+
# add empty records for IDs, that are present in context, but not in target; i.e., for zero-sequence records
|
|
143
|
+
if has_context:
|
|
144
|
+
zero_seq_ids = list(set(df_ctx[ctx_primary_key]) - set(df[tgt_context_key]))
|
|
145
|
+
df_miss = pd.DataFrame({tgt_context_key: zero_seq_ids})
|
|
146
|
+
df_pads = pd.DataFrame({c: [[]] for c in df.columns if c != tgt_context_key})
|
|
147
|
+
df_miss = df_miss.merge(df_pads, how="cross")
|
|
148
|
+
df = pd.concat([df, df_miss], axis=0).reset_index(drop=True)
|
|
149
|
+
# pad each list with one extra item
|
|
150
|
+
df = pad_horizontally(df, padding_value=0, right=True)
|
|
151
|
+
elif has_context:
|
|
152
|
+
# add 0-rows for IDs, that are present in context, but not in target; i.e., for zero-sequence records
|
|
153
|
+
zero_seq_ids = list(set(df_ctx[ctx_primary_key]) - set(df[tgt_context_key]))
|
|
154
|
+
df_miss = pd.DataFrame({tgt_context_key: zero_seq_ids})
|
|
155
|
+
df_pads = pd.DataFrame({c: [0] for c in df.columns if c != tgt_context_key})
|
|
156
|
+
df_miss = df_miss.merge(df_pads, how="cross")
|
|
157
|
+
df = pd.concat([df, df_miss], axis=0).reset_index(drop=True)
|
|
158
|
+
# ensure that max 1 item is retained per context_id for flat mode
|
|
159
|
+
df = df[df.groupby(tgt_context_key).cumcount() < 1]
|
|
160
|
+
|
|
161
|
+
# merge context with target
|
|
162
|
+
if has_context:
|
|
163
|
+
df = df_ctx.merge(df, left_on=ctx_primary_key, right_on=tgt_context_key, how="inner")
|
|
164
|
+
|
|
165
|
+
# drop all key columns
|
|
166
|
+
keys = [tgt_context_key, ctx_primary_key]
|
|
167
|
+
df.drop(columns=keys, inplace=True, errors="ignore")
|
|
168
|
+
|
|
169
|
+
# return, if nothing to write or if no target columns are present
|
|
170
|
+
tgt_columns = [c for c in df.columns if c.startswith(TGT)]
|
|
171
|
+
if df.empty or len(tgt_columns) == 0:
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
# shuffle and persist to disk as parquet files
|
|
175
|
+
df = df.sample(frac=1)
|
|
176
|
+
df.to_parquet(output_path / tgt_partition_file.name, engine="pyarrow", index=False)
|
|
177
|
+
_LOG.info(f"encoded partition {tgt_partition_file.name} {df.shape}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def encode_df(
|
|
181
|
+
df: pd.DataFrame,
|
|
182
|
+
stats: dict,
|
|
183
|
+
ctx_primary_key: str | None = None,
|
|
184
|
+
tgt_context_key: str | None = None,
|
|
185
|
+
n_jobs: int = 1,
|
|
186
|
+
) -> tuple[pd.DataFrame, str | None, str | None]:
|
|
187
|
+
"""
|
|
188
|
+
Encodes a given table represented by a DataFrame object. The result will be delivered
|
|
189
|
+
as DataFrame, as well.
|
|
190
|
+
|
|
191
|
+
:param df: input data to be encoded
|
|
192
|
+
:param stats: stats for each of the columns
|
|
193
|
+
:param ctx_primary_key: context primary key
|
|
194
|
+
:param tgt_context_key: target context key
|
|
195
|
+
:return: encoded data and keys following columns' naming conventions
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
if ctx_primary_key and ctx_primary_key not in df:
|
|
199
|
+
raise ValueError(f"primary key `{ctx_primary_key}` not present")
|
|
200
|
+
if tgt_context_key and tgt_context_key not in df:
|
|
201
|
+
raise ValueError(f"context key `{tgt_context_key}` not present")
|
|
202
|
+
if ctx_primary_key and df[[ctx_primary_key]].duplicated().any():
|
|
203
|
+
raise ValueError("Duplicate primary keys in table")
|
|
204
|
+
|
|
205
|
+
df_columns = []
|
|
206
|
+
|
|
207
|
+
# key columns
|
|
208
|
+
context_keys = df[tgt_context_key] if tgt_context_key in df.columns else None
|
|
209
|
+
if ctx_primary_key is not None:
|
|
210
|
+
unique_primary_key = f"flt/primary_key.{ctx_primary_key}"
|
|
211
|
+
df_columns.append(df[ctx_primary_key].reset_index(drop=True).to_frame(unique_primary_key))
|
|
212
|
+
ctx_primary_key = unique_primary_key
|
|
213
|
+
if tgt_context_key is not None:
|
|
214
|
+
unique_context_key = f"context_key.{tgt_context_key}"
|
|
215
|
+
df_columns.append(df[tgt_context_key].reset_index(drop=True).to_frame(unique_context_key))
|
|
216
|
+
tgt_context_key = unique_context_key
|
|
217
|
+
|
|
218
|
+
# data columns
|
|
219
|
+
stats = stats or {"columns": {}}
|
|
220
|
+
delayed_encodes = []
|
|
221
|
+
for column in stats["columns"].keys():
|
|
222
|
+
if column not in df:
|
|
223
|
+
# skip encoding for columns that are not present in the data
|
|
224
|
+
continue
|
|
225
|
+
column_stats = stats["columns"][column]
|
|
226
|
+
if not set(column_stats.keys()) - {"encoding_type"}:
|
|
227
|
+
# all partitions are empty; skip encoding
|
|
228
|
+
continue
|
|
229
|
+
# encode (empty and non-empty) column
|
|
230
|
+
delayed_encodes.append(
|
|
231
|
+
delayed(_encode_col)(
|
|
232
|
+
values=df[column],
|
|
233
|
+
column_stats=column_stats,
|
|
234
|
+
context_keys=context_keys,
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
if delayed_encodes:
|
|
238
|
+
with parallel_config("loky", n_jobs=n_jobs):
|
|
239
|
+
df_columns.extend(Parallel()(delayed_encodes))
|
|
240
|
+
|
|
241
|
+
df = pd.concat(df_columns, axis=1) if df_columns else pd.DataFrame()
|
|
242
|
+
return df, ctx_primary_key, tgt_context_key
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _encode_col(
|
|
246
|
+
values: pd.Series,
|
|
247
|
+
column_stats: dict,
|
|
248
|
+
context_keys: pd.Series | None = None,
|
|
249
|
+
) -> pd.DataFrame:
|
|
250
|
+
is_sequential_column = is_sequential(values)
|
|
251
|
+
if is_sequential_column:
|
|
252
|
+
# explode nested columns and encode the same way as flat columns
|
|
253
|
+
non_empties = values.apply(lambda v: len(v) if is_a_list(v) else 1) > 0
|
|
254
|
+
# generate serial context_keys, if context_keys are not provided
|
|
255
|
+
context_keys = (
|
|
256
|
+
context_keys
|
|
257
|
+
if context_keys is not None
|
|
258
|
+
else pd.Series(range(len(values)), index=values.index).rename("__ckey")
|
|
259
|
+
)
|
|
260
|
+
# explode non-empty values and context_keys in sync, remember sequence keys, reset index afterwards
|
|
261
|
+
df = pd.concat([values[non_empties], context_keys[non_empties]], axis=1)
|
|
262
|
+
df = df.explode(values.name)
|
|
263
|
+
# trim sequences to max_seq_len
|
|
264
|
+
max_seq_len = column_stats.get("seq_len", {}).get("max")
|
|
265
|
+
if max_seq_len:
|
|
266
|
+
df = df[df.groupby(context_keys.name).cumcount() < max_seq_len]
|
|
267
|
+
sequence_keys = df.index
|
|
268
|
+
df = df.reset_index(drop=True)
|
|
269
|
+
values, context_keys = df[values.name], df[context_keys.name]
|
|
270
|
+
|
|
271
|
+
df = _encode_flat_col(
|
|
272
|
+
encoding_type=column_stats["encoding_type"],
|
|
273
|
+
values=values,
|
|
274
|
+
column_stats=column_stats,
|
|
275
|
+
context_keys=context_keys,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
if is_sequential_column:
|
|
279
|
+
# flatten encoded, non-empty sequences
|
|
280
|
+
encoded_sequences = flatten_frame(df.assign(__sequence_key=sequence_keys), "__sequence_key").set_index(
|
|
281
|
+
"__sequence_key"
|
|
282
|
+
)
|
|
283
|
+
# inject empty sequences back into their original positions
|
|
284
|
+
empty_sequences = pd.DataFrame(index=non_empties.index[~non_empties], columns=encoded_sequences.columns).map(
|
|
285
|
+
lambda _: []
|
|
286
|
+
)
|
|
287
|
+
df = pd.concat([encoded_sequences, empty_sequences]).sort_index()
|
|
288
|
+
|
|
289
|
+
df.reset_index(drop=True, inplace=True)
|
|
290
|
+
|
|
291
|
+
if column_stats.get(ARGN_TABLE) is not None:
|
|
292
|
+
# assign sanitized, unique column names for usage in ARGN
|
|
293
|
+
df.columns = [
|
|
294
|
+
get_argn_name(
|
|
295
|
+
argn_processor=column_stats[ARGN_PROCESSOR],
|
|
296
|
+
argn_table=column_stats[ARGN_TABLE],
|
|
297
|
+
argn_column=column_stats[ARGN_COLUMN],
|
|
298
|
+
argn_sub_column=sub_column,
|
|
299
|
+
)
|
|
300
|
+
for sub_column in df.columns
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
return df
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _encode_flat_col(
|
|
307
|
+
encoding_type: ModelEncodingType,
|
|
308
|
+
values: pd.Series,
|
|
309
|
+
column_stats: dict,
|
|
310
|
+
context_keys: pd.Series | None,
|
|
311
|
+
) -> pd.DataFrame:
|
|
312
|
+
if encoding_type == ModelEncodingType.tabular_categorical:
|
|
313
|
+
df = encode_categorical(values, column_stats, context_keys)
|
|
314
|
+
elif encoding_type in [
|
|
315
|
+
ModelEncodingType.tabular_numeric_discrete,
|
|
316
|
+
ModelEncodingType.tabular_numeric_binned,
|
|
317
|
+
ModelEncodingType.tabular_numeric_digit,
|
|
318
|
+
]:
|
|
319
|
+
df = encode_numeric(values, column_stats, context_keys)
|
|
320
|
+
elif encoding_type == ModelEncodingType.tabular_datetime:
|
|
321
|
+
df = encode_datetime(values, column_stats, context_keys)
|
|
322
|
+
elif encoding_type == ModelEncodingType.tabular_datetime_relative:
|
|
323
|
+
df = encode_itt(values, column_stats, context_keys)
|
|
324
|
+
elif encoding_type == ModelEncodingType.tabular_character:
|
|
325
|
+
df = encode_character(values, column_stats, context_keys)
|
|
326
|
+
elif encoding_type == ModelEncodingType.tabular_lat_long:
|
|
327
|
+
df = encode_latlong(values, column_stats, context_keys)
|
|
328
|
+
else:
|
|
329
|
+
raise RuntimeError(f"unknown encoding_type `{encoding_type}`")
|
|
330
|
+
return df
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def flatten_frame(df: pd.DataFrame, group_key: str) -> pd.DataFrame:
|
|
334
|
+
"""
|
|
335
|
+
Flattens non-key columns. See also `explode_frame` for reverse.
|
|
336
|
+
|
|
337
|
+
This is an optimized flattening of data to rows by a given foreign key column. It is equivalent to
|
|
338
|
+
`return data.groupby(foreign_key_column).agg(list).reset_index(level=0)`, but was benchmarked being
|
|
339
|
+
significantly faster
|
|
340
|
+
|
|
341
|
+
:example:
|
|
342
|
+
this method converts
|
|
343
|
+
key product is_paid
|
|
344
|
+
1 3 0
|
|
345
|
+
1 2 1
|
|
346
|
+
2 9 1
|
|
347
|
+
to
|
|
348
|
+
key product is_paid
|
|
349
|
+
1 [3, 2] [0, 1]
|
|
350
|
+
2 [9] [1]
|
|
351
|
+
"""
|
|
352
|
+
orig_columns_order = df.columns
|
|
353
|
+
sorted_data = (
|
|
354
|
+
df.reset_index(drop=True).rename_axis("__flatten_index").sort_values(by=[group_key, "__flatten_index"])
|
|
355
|
+
) # pre-group rows by FK, and preserve the order of their appearance by the index
|
|
356
|
+
|
|
357
|
+
keys = sorted_data.pop(group_key).values.T
|
|
358
|
+
values = sorted_data.values.T
|
|
359
|
+
column_names = sorted_data.columns.values
|
|
360
|
+
unique_keys, index = np.unique(keys, True) # cutoff indexes for the groups (by FK)
|
|
361
|
+
grouped_values = [
|
|
362
|
+
np.split(value, index[1:]) for value in values
|
|
363
|
+
] # a list of lists of arrays: column -> rows as a list -> grouped array of values
|
|
364
|
+
|
|
365
|
+
flattened_data = {group_key: unique_keys}
|
|
366
|
+
flattened_data.update(
|
|
367
|
+
{
|
|
368
|
+
column_name: [list(group) for group in column_values]
|
|
369
|
+
for column_name, column_values in zip(column_names, grouped_values)
|
|
370
|
+
}
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
flattened_data = (
|
|
374
|
+
pd.DataFrame(flattened_data).reindex(columns=orig_columns_order)
|
|
375
|
+
if len(unique_keys) > 0
|
|
376
|
+
else pd.DataFrame(columns=orig_columns_order)
|
|
377
|
+
)
|
|
378
|
+
return flattened_data
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _enrich_slen_sidx_sdec(df: pd.DataFrame, context_key: str, max_seq_len: int) -> pd.DataFrame:
|
|
382
|
+
df = df.reset_index(drop=True)
|
|
383
|
+
slen = df.groupby(context_key)[context_key].transform("size") # sequence length
|
|
384
|
+
sidx = df.groupby(context_key).cumcount(ascending=True) # sequence index
|
|
385
|
+
sdec = (10 * sidx / slen.clip(lower=1)).astype(int) # sequence index decile
|
|
386
|
+
slen = encode_slen_sidx_sdec(slen, max_seq_len=max_seq_len, prefix=SLEN_SUB_COLUMN_PREFIX)
|
|
387
|
+
sidx = encode_slen_sidx_sdec(sidx, max_seq_len=max_seq_len, prefix=SIDX_SUB_COLUMN_PREFIX)
|
|
388
|
+
sdec = encode_slen_sidx_sdec(sdec, max_seq_len=max_seq_len, prefix=SDEC_SUB_COLUMN_PREFIX)
|
|
389
|
+
df = pd.concat([slen, sidx, sdec, df], axis=1)
|
|
390
|
+
return df
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def pad_horizontally(df: pd.DataFrame, padding_value: int, right=True) -> pd.DataFrame:
|
|
394
|
+
if df.shape[0] == 0:
|
|
395
|
+
return df
|
|
396
|
+
list_cols = [c for c in df.columns if is_a_list(df.loc[0, c])]
|
|
397
|
+
|
|
398
|
+
def pad_right(x):
|
|
399
|
+
return x + [padding_value] if len(x) == 0 else x
|
|
400
|
+
|
|
401
|
+
def pad_left(x):
|
|
402
|
+
return [padding_value] + x if len(x) == 0 else x
|
|
403
|
+
|
|
404
|
+
for col in list_cols:
|
|
405
|
+
df[col] = df[col].apply(pad_right if right else pad_left)
|
|
406
|
+
return df
|
|
@@ -0,0 +1,245 @@
|
|
|
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 functools import partial
|
|
17
|
+
from typing import Any, TypedDict
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
|
|
20
|
+
import pandas as pd
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from itertools import product
|
|
24
|
+
import torch
|
|
25
|
+
|
|
26
|
+
from mostlyai.engine._common import (
|
|
27
|
+
get_argn_name,
|
|
28
|
+
ARGN_COLUMN,
|
|
29
|
+
ARGN_PROCESSOR,
|
|
30
|
+
ARGN_TABLE,
|
|
31
|
+
)
|
|
32
|
+
from mostlyai.engine._encoding_types.tabular.categorical import CATEGORICAL_SUB_COL_SUFFIX, CATEGORICAL_UNKNOWN_TOKEN
|
|
33
|
+
from mostlyai.engine.domain import ModelEncodingType, FairnessConfig
|
|
34
|
+
|
|
35
|
+
_LOG = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FairnessTransforms(TypedDict):
|
|
39
|
+
target_sub_col: str
|
|
40
|
+
sensitive_sub_cols: list[str]
|
|
41
|
+
transforms: dict[int, dict[tuple[int], callable]]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_sensitive_groups(
|
|
45
|
+
column_stats: dict[str, Any],
|
|
46
|
+
sensitive_cols: list[str],
|
|
47
|
+
sensitive_sub_cols: list[str],
|
|
48
|
+
) -> pd.DataFrame:
|
|
49
|
+
"""
|
|
50
|
+
Create a DataFrame with all possible combinations of (encoded) values for the sensitive columns.
|
|
51
|
+
"""
|
|
52
|
+
category_map = {}
|
|
53
|
+
for sensitive_col in sensitive_cols:
|
|
54
|
+
category_map[sensitive_col] = list(column_stats[sensitive_col]["codes"].values())
|
|
55
|
+
# when there are no rare categories, we can exclude _RARE_ to reduce the number of combinations
|
|
56
|
+
if column_stats[sensitive_col]["no_of_rare_categories"] == 0:
|
|
57
|
+
category_map[sensitive_col].remove(column_stats[sensitive_col]["codes"][CATEGORICAL_UNKNOWN_TOKEN])
|
|
58
|
+
groups_df = pd.DataFrame(list(product(*category_map.values())), columns=sensitive_sub_cols)
|
|
59
|
+
_LOG.info(f"fairness: {groups_df.shape[0]} sensitive groups")
|
|
60
|
+
return groups_df
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _estimate_target_distribution(
|
|
64
|
+
forward_fn: Callable,
|
|
65
|
+
device: str | torch.device,
|
|
66
|
+
target_sub_col: str,
|
|
67
|
+
sample_size: int,
|
|
68
|
+
n_quantiles: int = 101,
|
|
69
|
+
seed_values: dict[str, int] | None = None,
|
|
70
|
+
) -> np.ndarray:
|
|
71
|
+
"""
|
|
72
|
+
Estimate the (conditional or unconditional) empirical distribution of the target column by fetching model probabilities for
|
|
73
|
+
that column, and then computing the quantiles of that empirical distribution.
|
|
74
|
+
|
|
75
|
+
:return: A numpy array of shape (n_quantiles, target_cardinality).
|
|
76
|
+
"""
|
|
77
|
+
if seed_values is None:
|
|
78
|
+
fixed_values = None
|
|
79
|
+
_LOG.info(f"fairness: sample {sample_size:,} unconditionally")
|
|
80
|
+
else:
|
|
81
|
+
fixed_values = {
|
|
82
|
+
col: torch.as_tensor([values] * sample_size, device=device).type(torch.int)
|
|
83
|
+
for col, values in seed_values.items()
|
|
84
|
+
}
|
|
85
|
+
_LOG.info(f"fairness: sample {sample_size:,} for sensitive group {seed_values}")
|
|
86
|
+
|
|
87
|
+
# fetch probs for target column
|
|
88
|
+
_, probs = forward_fn(
|
|
89
|
+
x={},
|
|
90
|
+
mode="gen",
|
|
91
|
+
batch_size=sample_size,
|
|
92
|
+
fixed_values=fixed_values,
|
|
93
|
+
return_probs=[target_sub_col],
|
|
94
|
+
)
|
|
95
|
+
# convert to numpy
|
|
96
|
+
probs = probs[target_sub_col].detach().cpu().numpy()
|
|
97
|
+
# calculate quantiles, and wrap with 0 a
|
|
98
|
+
quantiles = np.concatenate(
|
|
99
|
+
[
|
|
100
|
+
np.zeros((1, probs.shape[1])),
|
|
101
|
+
np.quantile(probs, np.linspace(0, 1, n_quantiles), axis=0),
|
|
102
|
+
np.ones((1, probs.shape[1])),
|
|
103
|
+
]
|
|
104
|
+
)
|
|
105
|
+
return quantiles
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_fairness_transforms(
|
|
109
|
+
forward_fn: Callable,
|
|
110
|
+
device: str | torch.device,
|
|
111
|
+
fairness: FairnessConfig,
|
|
112
|
+
tgt_stats: dict[str, Any],
|
|
113
|
+
) -> FairnessTransforms:
|
|
114
|
+
"""
|
|
115
|
+
Generate the transforms for the target column conditioned on the sensitive columns.
|
|
116
|
+
"""
|
|
117
|
+
_LOG.info(
|
|
118
|
+
f"fairness: create transforms for target `{fairness.target_column}` across "
|
|
119
|
+
f"sensitive columns: {''.join(fairness.sensitive_columns)}"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# validate the fairness configuration
|
|
123
|
+
if fairness.sensitive_columns is None or len(fairness.sensitive_columns) == 0:
|
|
124
|
+
raise ValueError("The sensitive columns must be specified in the fairness configuration")
|
|
125
|
+
if fairness.target_column is None:
|
|
126
|
+
raise ValueError("The target column must be specified in the fairness configuration")
|
|
127
|
+
if fairness.target_column in fairness.sensitive_columns:
|
|
128
|
+
raise ValueError("The target column cannot be a sensitive column")
|
|
129
|
+
cols = [fairness.target_column] + fairness.sensitive_columns
|
|
130
|
+
for col in cols:
|
|
131
|
+
if col not in tgt_stats["columns"]:
|
|
132
|
+
raise ValueError(f"Invalid fairness column: `{col}` not found in the training data")
|
|
133
|
+
if tgt_stats["columns"][col]["encoding_type"] != ModelEncodingType.tabular_categorical:
|
|
134
|
+
raise ValueError(f"Invalid fairness column: `{col}` is not encoded categorically")
|
|
135
|
+
|
|
136
|
+
# estimate the marginal target distribution
|
|
137
|
+
target_sub_col = get_argn_name(
|
|
138
|
+
argn_processor=tgt_stats["columns"][fairness.target_column][ARGN_PROCESSOR],
|
|
139
|
+
argn_table=tgt_stats["columns"][fairness.target_column][ARGN_TABLE],
|
|
140
|
+
argn_column=tgt_stats["columns"][fairness.target_column][ARGN_COLUMN],
|
|
141
|
+
argn_sub_column=CATEGORICAL_SUB_COL_SUFFIX,
|
|
142
|
+
)
|
|
143
|
+
marginal_target_quantiles: np.ndarray = _estimate_target_distribution(
|
|
144
|
+
forward_fn=forward_fn,
|
|
145
|
+
device=device,
|
|
146
|
+
target_sub_col=target_sub_col,
|
|
147
|
+
sample_size=15_000,
|
|
148
|
+
n_quantiles=101,
|
|
149
|
+
seed_values=None,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# estimate the conditional target distribution for each combination of sensitive values
|
|
153
|
+
sensitive_sub_cols = [
|
|
154
|
+
get_argn_name(
|
|
155
|
+
argn_processor=tgt_stats["columns"][col][ARGN_PROCESSOR],
|
|
156
|
+
argn_table=tgt_stats["columns"][col][ARGN_TABLE],
|
|
157
|
+
argn_column=tgt_stats["columns"][col][ARGN_COLUMN],
|
|
158
|
+
argn_sub_column=CATEGORICAL_SUB_COL_SUFFIX,
|
|
159
|
+
)
|
|
160
|
+
for col in fairness.sensitive_columns
|
|
161
|
+
]
|
|
162
|
+
sensitive_groups_df = _get_sensitive_groups(
|
|
163
|
+
column_stats=tgt_stats["columns"],
|
|
164
|
+
sensitive_cols=fairness.sensitive_columns,
|
|
165
|
+
sensitive_sub_cols=sensitive_sub_cols,
|
|
166
|
+
)
|
|
167
|
+
conditional_target_quantiles: dict[tuple[int], np.ndarray] = {}
|
|
168
|
+
for _, sensitive_group in sensitive_groups_df.iterrows():
|
|
169
|
+
seed_values = sensitive_group.to_dict()
|
|
170
|
+
conditional_target_quantiles[tuple(seed_values.values())] = _estimate_target_distribution(
|
|
171
|
+
forward_fn=forward_fn,
|
|
172
|
+
device=device,
|
|
173
|
+
target_sub_col=target_sub_col,
|
|
174
|
+
sample_size=5_000,
|
|
175
|
+
n_quantiles=101,
|
|
176
|
+
seed_values=seed_values,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# calculate the transforms, one for each target value and sensitive group
|
|
180
|
+
transforms: dict[int, dict[tuple[int], callable]] = {}
|
|
181
|
+
for i in range(marginal_target_quantiles.shape[1]):
|
|
182
|
+
transforms[i] = {}
|
|
183
|
+
for sensitive_group, target_quantiles in conditional_target_quantiles.items():
|
|
184
|
+
transforms[i][sensitive_group] = partial(
|
|
185
|
+
torch_interp,
|
|
186
|
+
xp=torch.as_tensor(target_quantiles[:, i], device=device),
|
|
187
|
+
fp=torch.as_tensor(marginal_target_quantiles[:, i], device=device),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
_LOG.info("fairness: created transforms")
|
|
191
|
+
return {
|
|
192
|
+
"target_sub_col": target_sub_col,
|
|
193
|
+
"sensitive_sub_cols": sensitive_sub_cols,
|
|
194
|
+
"transforms": transforms,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def apply_fairness_transforms(
|
|
199
|
+
sub_col: str, probs: torch.Tensor, outputs: dict[str, torch.Tensor], fairness_transforms: FairnessTransforms
|
|
200
|
+
) -> torch.Tensor:
|
|
201
|
+
"""
|
|
202
|
+
Apply fairness transforms to the generated probabilities of the target column.
|
|
203
|
+
|
|
204
|
+
:param sub_col: The current sub column.
|
|
205
|
+
:param probs: The probabilities for the current sub column.
|
|
206
|
+
:param outputs: The sampled values for the sensitive columns.
|
|
207
|
+
:param fairness_transforms: The transform callables to apply.
|
|
208
|
+
:return: The transformed probabilities for the target column.
|
|
209
|
+
"""
|
|
210
|
+
# only transform the target column
|
|
211
|
+
device = probs.device
|
|
212
|
+
if sub_col == fairness_transforms["target_sub_col"]:
|
|
213
|
+
sensitive_values = torch.stack([outputs[col] for col in fairness_transforms["sensitive_sub_cols"]]).t()
|
|
214
|
+
for i, i_transforms in fairness_transforms["transforms"].items():
|
|
215
|
+
for group, transform in i_transforms.items():
|
|
216
|
+
probs[:, i] = torch.where(
|
|
217
|
+
torch.all(
|
|
218
|
+
sensitive_values == torch.as_tensor(group, device=device).tile((probs.shape[0], 1)),
|
|
219
|
+
dim=1,
|
|
220
|
+
),
|
|
221
|
+
transform(probs[:, i]),
|
|
222
|
+
probs[:, i],
|
|
223
|
+
)
|
|
224
|
+
# normalize transformed probabilities to make sure they sum to 1
|
|
225
|
+
probs /= probs.sum(dim=1, keepdim=True)
|
|
226
|
+
return probs
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def torch_interp(x: torch.Tensor, xp: torch.Tensor, fp: torch.Tensor) -> torch.Tensor:
|
|
230
|
+
"""
|
|
231
|
+
Torch implementation of numpy.interp.
|
|
232
|
+
"""
|
|
233
|
+
# find the indices of the bins to which each value in x belongs
|
|
234
|
+
indices = torch.searchsorted(xp, x, side="right")
|
|
235
|
+
indices = torch.clamp(indices, 1, len(xp) - 1)
|
|
236
|
+
# compute the slopes of the segments
|
|
237
|
+
x_lo = xp[indices - 1]
|
|
238
|
+
x_hi = xp[indices]
|
|
239
|
+
y_lo = fp[indices - 1]
|
|
240
|
+
y_hi = fp[indices]
|
|
241
|
+
# compute the interpolated values
|
|
242
|
+
eps = 1e-10
|
|
243
|
+
slopes = (y_hi - y_lo) / (x_hi - x_lo + eps)
|
|
244
|
+
interpolated_values = y_lo + slopes * (x - x_lo)
|
|
245
|
+
return interpolated_values
|