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,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