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,13 @@
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.
@@ -0,0 +1,13 @@
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.
@@ -0,0 +1,41 @@
1
+ # Copyright 2025 MOSTLY AI
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import pandas as pd
16
+
17
+ from mostlyai.engine._common import safe_convert_string
18
+
19
+
20
+ def analyze_text(values: pd.Series, root_keys: pd.Series, _: pd.Series | None = None) -> dict:
21
+ # ideally, we should ensure that values are converted to string in a consistent way across analyze/encode/qa steps
22
+ values = safe_convert_string(values)
23
+ nchars = values.map(str).str.len()
24
+ stats = {"nchar_max": int(nchars.max()), "nchar_sum": int(nchars.sum()), "count": len(values)}
25
+ return stats
26
+
27
+
28
+ def analyze_reduce_text(stats_list: list[dict], _: bool = True) -> dict:
29
+ nchar_max = 0
30
+ nchar_sum = 0
31
+ count = 0
32
+ for stats in stats_list:
33
+ nchar_max = max(stats["nchar_max"], nchar_max)
34
+ nchar_sum += stats["nchar_sum"]
35
+ count += stats["count"]
36
+
37
+ stats = {
38
+ "nchar_avg": round(nchar_sum / count, 1),
39
+ "nchar_max": nchar_max,
40
+ }
41
+ return stats
@@ -0,0 +1,13 @@
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.
@@ -0,0 +1,115 @@
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
+ Categorical encoding maps each categorical value to its own integer code.
17
+ """
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+
22
+ from mostlyai.engine._common import safe_convert_string
23
+
24
+ CATEGORICAL_UNKNOWN_TOKEN = "_RARE_"
25
+ CATEGORICAL_NULL_TOKEN = "<<NULL>>"
26
+ CATEGORICAL_SUB_COL_SUFFIX = "cat"
27
+ CATEGORICAL_ESCAPE_CHAR = "\x01"
28
+
29
+
30
+ def safe_categorical_escape(values: pd.Series) -> pd.Series:
31
+ """Inplace escaping of categorical values"""
32
+ reserved_tokens = (CATEGORICAL_UNKNOWN_TOKEN, CATEGORICAL_NULL_TOKEN)
33
+ reserved_tokens_replacement_map = {t: CATEGORICAL_ESCAPE_CHAR + t for t in reserved_tokens}
34
+ # first, prefix values starting with escape char with another escape char
35
+ mask = values.str.startswith(CATEGORICAL_ESCAPE_CHAR, na=False)
36
+ values.loc[mask] = values.loc[mask].str.slice_replace(stop=1, repl=CATEGORICAL_ESCAPE_CHAR * 2)
37
+ # second, add escape char to all reserved tokens
38
+ values = values.replace(reserved_tokens_replacement_map)
39
+ return values
40
+
41
+
42
+ def safe_categorical_unescape(values: pd.Series) -> pd.Series:
43
+ """Inplace un-escaping of categorical values"""
44
+ # de-prefix all values starting with escape char by removing just the first one
45
+ mask = values.str.startswith(CATEGORICAL_ESCAPE_CHAR, na=False)
46
+ values.loc[mask] = values.loc[mask].str[1:]
47
+ return values
48
+
49
+
50
+ def analyze_categorical(values: pd.Series, root_keys: pd.Series, _: pd.Series | None = None) -> dict:
51
+ # ensure a safe representation of values: 1. string dtype; 2. escape reserved tokens
52
+ values = safe_categorical_escape(safe_convert_string(values))
53
+ # count distinct root_keys per categorical value for rare-category protection
54
+ df = pd.concat([root_keys, values], axis=1)
55
+ cnt_values = df.groupby(values.name)[root_keys.name].nunique().to_dict()
56
+ stats = {"has_nan": sum(values.isna()) > 0, "cnt_values": cnt_values}
57
+ return stats
58
+
59
+
60
+ def analyze_reduce_categorical(stats_list: list[dict], value_protection: bool = True) -> dict:
61
+ # sum up all counts for each categorical value
62
+ cnt_values: dict[str, int] = {}
63
+ for item in stats_list:
64
+ for value, count in item["cnt_values"].items():
65
+ cnt_values[value] = cnt_values.get(value, 0) + count
66
+ # create alphabetically sorted list of non-rare categories
67
+ known_categories = [k for k in sorted(cnt_values.keys())]
68
+ if value_protection:
69
+ # stochastic threshold for rare categories
70
+ rare_min = 5 + int(3 * np.random.uniform())
71
+ else:
72
+ rare_min = 0
73
+ categories = [k for k in known_categories if cnt_values[k] >= rare_min]
74
+ no_of_rare_categories = len(known_categories) - len(categories)
75
+ # add special token for MISSING categories, if any are present
76
+ if any([j["has_nan"] for j in stats_list]):
77
+ categories = [CATEGORICAL_NULL_TOKEN] + categories
78
+ # add special token for UNKNOWN categories at first position
79
+ categories = [CATEGORICAL_UNKNOWN_TOKEN] + categories
80
+ stats = {
81
+ "no_of_rare_categories": no_of_rare_categories,
82
+ "codes": {categories[i]: i for i in range(len(categories))},
83
+ "cardinalities": {CATEGORICAL_SUB_COL_SUFFIX: len(categories)},
84
+ }
85
+ return stats
86
+
87
+
88
+ def encode_categorical(values: pd.Series, stats: dict, _: pd.Series | None = None) -> pd.DataFrame:
89
+ # ensure a safe representation of values: 1. string dtype; 2. escape reserved tokens
90
+ values = safe_categorical_escape(safe_convert_string(values))
91
+ known_categories = [str(k) for k in stats["codes"].keys()]
92
+ values = values.copy()
93
+ if CATEGORICAL_NULL_TOKEN in known_categories:
94
+ values[values.isna()] = CATEGORICAL_NULL_TOKEN
95
+ values[~values.isin(known_categories)] = CATEGORICAL_UNKNOWN_TOKEN
96
+
97
+ # map categories to their corresponding codes
98
+ codes = pd.Series(
99
+ pd.Categorical(values, categories=known_categories).codes,
100
+ name=CATEGORICAL_SUB_COL_SUFFIX,
101
+ index=values.index,
102
+ )
103
+ return codes.to_frame()
104
+
105
+
106
+ def decode_categorical(df_encoded: pd.DataFrame, stats: dict) -> pd.Series:
107
+ categories = stats["codes"].keys()
108
+ values = pd.Series(
109
+ pd.Categorical.from_codes(df_encoded[CATEGORICAL_SUB_COL_SUFFIX], categories=categories),
110
+ dtype="string",
111
+ )
112
+ values[values == CATEGORICAL_NULL_TOKEN] = pd.NA
113
+ # convert escaped values to their original representation
114
+ values = safe_categorical_unescape(values)
115
+ return values
@@ -0,0 +1,155 @@
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
+ Character encoding splits any value into its characters, and encodes each position then separately as a categorical.
17
+ """
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+
22
+ from mostlyai.engine._common import safe_convert_string
23
+
24
+ UNKNOWN_TOKEN = "\0"
25
+ MAX_LENGTH_CHARS = 50
26
+
27
+
28
+ def analyze_character(values: pd.Series, root_keys: pd.Series, _: pd.Series | None = None) -> dict:
29
+ values = safe_convert_string(values)
30
+ df_split = split_sub_columns_character(values)
31
+ has_nan = sum(df_split["nan"]) > 0
32
+ # count distinct root_keys per token for each character position
33
+ df = pd.concat([root_keys, df_split], axis=1)
34
+ characters = {
35
+ sub_col: df.groupby(sub_col)[root_keys.name].nunique().to_dict()
36
+ for sub_col in df_split.columns
37
+ if sub_col.startswith("P")
38
+ }
39
+ stats = {
40
+ "max_string_length": len(characters),
41
+ "has_nan": has_nan,
42
+ "characters": characters,
43
+ }
44
+ return stats
45
+
46
+
47
+ def analyze_reduce_character(stats_list: list[dict], value_protection: bool = True) -> dict:
48
+ # gather maximum string length across partitions
49
+ max_string_length = max(stats["max_string_length"] for stats in stats_list)
50
+ positions = [f"P{idx}" for idx in range(max_string_length)]
51
+ # gather codes for each position
52
+ codes: dict[str, dict[str, int]] = {pos: {} for pos in positions}
53
+ for pos in positions:
54
+ cnt_values: dict[str, int] = {}
55
+ # sum up all counts for each token
56
+ for item in stats_list:
57
+ for value, count in item["characters"].get(pos, {}).items():
58
+ cnt_values[value] = cnt_values.get(value, 0) + count
59
+ # create alphabetically sorted list of non-rare tokens
60
+ known_categories = [k for k in sorted(cnt_values.keys())]
61
+ if value_protection:
62
+ # stochastic threshold for rare tokens
63
+ rare_min = 5 + int(3 * np.random.uniform())
64
+ else:
65
+ rare_min = 0
66
+ categories = [k for k in known_categories if cnt_values[k] >= rare_min]
67
+ # add special token for UNKNOWN at first position
68
+ categories = [UNKNOWN_TOKEN] + [c for c in categories if c != UNKNOWN_TOKEN]
69
+ # assign codes for each token
70
+ codes[pos] = {categories[i]: i for i in range(len(categories))}
71
+ # determine cardinalities
72
+ cardinalities = {}
73
+ has_nan = any([s["has_nan"] for s in stats_list])
74
+ if has_nan:
75
+ cardinalities["nan"] = 2 # binary
76
+ for sub_col, sub_col_codes in codes.items():
77
+ cardinalities[sub_col] = len(sub_col_codes)
78
+ stats = {
79
+ "has_nan": has_nan,
80
+ "max_string_length": max_string_length,
81
+ "codes": codes,
82
+ "cardinalities": cardinalities,
83
+ }
84
+ return stats
85
+
86
+
87
+ def encode_character(values: pd.Series, stats: dict, _: pd.Series | None = None) -> pd.DataFrame:
88
+ values = safe_convert_string(values)
89
+ max_string_length = stats["max_string_length"]
90
+ df_split = split_sub_columns_character(values, max_string_length)
91
+ if not stats["has_nan"]:
92
+ df_split.drop(["nan"], axis=1, inplace=True)
93
+ for idx in range(max_string_length):
94
+ sub_col = f"P{idx}"
95
+ np_codes = np.array(pd.Categorical(df_split[sub_col], categories=stats["codes"][sub_col]).codes)
96
+ np.place(np_codes, np_codes == -1, 0)
97
+ df_split[sub_col] = np_codes
98
+ return df_split
99
+
100
+
101
+ def split_sub_columns_character(
102
+ values: pd.Series,
103
+ max_string_length: int | None = None,
104
+ ) -> pd.DataFrame:
105
+ if not pd.api.types.is_string_dtype(values):
106
+ raise ValueError("expected to be string")
107
+ is_na = pd.Series(values.isna().astype("int"), name="nan").to_frame()
108
+ values = values.fillna("")
109
+ # trim strings to a maximum length
110
+ values = values.str.slice(stop=MAX_LENGTH_CHARS)
111
+ # pad strings to string_length
112
+ if max_string_length is None:
113
+ max_string_length = values.str.len().max()
114
+ max_string_length = (
115
+ int(max_string_length) # type: ignore
116
+ if np.isscalar(max_string_length) and not np.isnan(max_string_length)
117
+ else 0
118
+ )
119
+ else:
120
+ values = values.str.slice(stop=max_string_length)
121
+ # explode to wide dataframe
122
+ padded_values = values.str.ljust(max_string_length, UNKNOWN_TOKEN)
123
+ chars_df = padded_values.str.split("", expand=True)
124
+ if not chars_df.empty:
125
+ chars_df = chars_df.drop([0, max_string_length + 1], axis=1)
126
+ chars_df.columns = [f"P{idx}" for idx in range(max_string_length)]
127
+ else: # chars_df.empty is True
128
+ # even though the input is empty, we still need to return a dataframe with the correct columns
129
+ chars_df = pd.DataFrame(columns=[f"P{idx}" for idx in range(max_string_length)])
130
+ df = pd.concat([is_na, chars_df], axis=1)
131
+ return df
132
+
133
+
134
+ def decode_character(df_encoded: pd.DataFrame, stats: dict) -> pd.Series:
135
+ if len(stats["codes"].keys()) > 0:
136
+ df_decoded = pd.DataFrame(
137
+ {
138
+ sub_col: pd.Series(
139
+ pd.Categorical.from_codes(df_encoded[sub_col], categories=stats["codes"][sub_col]),
140
+ dtype="string",
141
+ )
142
+ for sub_col in stats["codes"].keys()
143
+ },
144
+ )
145
+ values = df_decoded.apply(lambda item: "".join(item), axis=1, result_type="reduce").astype(
146
+ str
147
+ ) # necessary to keep string dtype for empty df_decoded
148
+ # remove unknown tokens and strip trailing whitespaces
149
+ values = values.apply(lambda item: item.replace(UNKNOWN_TOKEN, "")).str.rstrip()
150
+ else:
151
+ # handle de-generate case, where no tokens were stored
152
+ values = pd.Series(pd.NA).repeat(df_encoded.shape[0])
153
+ if stats["has_nan"]:
154
+ values[df_encoded["nan"] == 1] = pd.NA
155
+ return values
@@ -0,0 +1,254 @@
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
+ Datetime encoding splits a datetime into its parts, and encodes each part separately. For each part its
17
+ minimum and maximum values are determined. Any part is then encoded as `x - min_value`, resulting in integers
18
+ ranging from 0 to `max_value - min_value`. Thus, the corresponding cardinality is `max_value + 1 - min_value`.
19
+ """
20
+
21
+ import calendar
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+ from dateutil import parser # type: ignore
26
+
27
+ from mostlyai.engine._common import safe_convert_datetime
28
+ from mostlyai.engine._dtypes import is_date_dtype, is_timestamp_dtype
29
+
30
+ DATETIME_PARTS = [
31
+ "year",
32
+ "month",
33
+ "day",
34
+ "hour",
35
+ "minute",
36
+ "second",
37
+ "ms_E2",
38
+ "ms_E1",
39
+ "ms_E0",
40
+ ]
41
+
42
+
43
+ def analyze_datetime(values: pd.Series, root_keys: pd.Series, _: pd.Series | None = None) -> dict:
44
+ values = safe_convert_datetime(values)
45
+ df = pd.concat([root_keys, values], axis=1)
46
+ # determine lowest/highest values by root ID, and return Top 10
47
+ min_dates = df.groupby(root_keys.name)[values.name].min().dropna()
48
+ min11 = min_dates.sort_values(ascending=True).head(11).astype(str).tolist()
49
+ max_dates = df.groupby(root_keys.name)[values.name].max().dropna()
50
+ max11 = max_dates.sort_values(ascending=False).head(11).astype(str).tolist()
51
+ # split into datetime parts
52
+ df_split = split_sub_columns_datetime(values)
53
+ is_not_nan = df_split["nan"] == 0
54
+ has_nan = any(df_split["nan"] == 1)
55
+ # extract min/max value for each part to determine valid value range
56
+ if any(is_not_nan):
57
+ min_values = {k: int(df_split[k][is_not_nan].min()) for k in DATETIME_PARTS}
58
+ max_values = {k: int(df_split[k][is_not_nan].max()) for k in DATETIME_PARTS}
59
+ else:
60
+ def_values = {"year": 2022, "month": 1, "day": 1}
61
+ min_values = {k: 0 for k in DATETIME_PARTS} | def_values
62
+ max_values = {k: 0 for k in DATETIME_PARTS} | def_values
63
+ # return stats
64
+ stats = {
65
+ "has_nan": has_nan,
66
+ "min_values": min_values,
67
+ "max_values": max_values,
68
+ "min11": min11,
69
+ "max11": max11,
70
+ }
71
+ return stats
72
+
73
+
74
+ def analyze_reduce_datetime(stats_list: list[dict], value_protection: bool = True) -> dict:
75
+ # check if there are missing values
76
+ has_nan = any([j["has_nan"] for j in stats_list])
77
+ # determine min/max values for each part
78
+ keys = stats_list[0]["min_values"].keys()
79
+ min_values = {k: min([j["min_values"][k] for j in stats_list]) for k in keys}
80
+ max_values = {k: max([j["max_values"][k] for j in stats_list]) for k in keys}
81
+ # check if any record has non-zero timestamp information
82
+ has_time = max_values["hour"] > 0 or max_values["minute"] > 0 or max_values["second"] > 0
83
+ has_ms = has_time and (max_values["ms_E2"] > 0 or max_values["ms_E1"] > 0 or max_values["ms_E0"] > 0)
84
+ # determine min / max 5 values to map too low / too high values to
85
+ min11 = sorted([v for min11 in [j["min11"] for j in stats_list] for v in min11], reverse=False)[:11]
86
+ max11 = sorted([v for max11 in [j["max11"] for j in stats_list] for v in max11], reverse=True)[:11]
87
+ if value_protection:
88
+ # extreme value protection - discard lowest/highest 5 values
89
+ if len(min11) < 11 or len(max11) < 11:
90
+ # less than 11 subjects with non-NULL values; we need to protect all
91
+ min5 = []
92
+ max5 = []
93
+ has_time = False
94
+ has_ms = False
95
+ else:
96
+ min5 = [str(v) for v in min11[5:10]] # drop 1 to 5th lowest; keep 6th to 10th lowest
97
+ max5 = [str(v) for v in max11[5:10]] # drop 1 to 5th highest; keep 6th to 10th highest
98
+ # update min/max year based on first four letters of protected min/max dates
99
+ max_values["year"] = int(max5[0][0:4])
100
+ min_values["year"] = int(min5[0][0:4])
101
+ else:
102
+ min5 = min11[0:4]
103
+ max5 = max11[0:4]
104
+ # determine cardinalities
105
+ cardinalities = {}
106
+ if has_nan:
107
+ cardinalities["nan"] = 2 # binary
108
+ cardinalities["year"] = max_values["year"] + 1 - min_values["year"]
109
+ cardinalities["month"] = max_values["month"] + 1 - min_values["month"]
110
+ cardinalities["day"] = max_values["day"] + 1 - min_values["day"]
111
+ if has_time:
112
+ cardinalities["hour"] = max_values["hour"] + 1 - min_values["hour"]
113
+ cardinalities["minute"] = max_values["minute"] + 1 - min_values["minute"]
114
+ cardinalities["second"] = max_values["second"] + 1 - min_values["second"]
115
+ if has_ms:
116
+ cardinalities["ms_E2"] = max_values["ms_E2"] + 1 - min_values["ms_E2"]
117
+ cardinalities["ms_E1"] = max_values["ms_E1"] + 1 - min_values["ms_E1"]
118
+ cardinalities["ms_E0"] = max_values["ms_E0"] + 1 - min_values["ms_E0"]
119
+ stats = {
120
+ "cardinalities": cardinalities,
121
+ "has_nan": has_nan,
122
+ "has_time": has_time,
123
+ "has_ms": has_ms,
124
+ "min_values": min_values,
125
+ "max_values": max_values,
126
+ "min5": min5,
127
+ "max5": max5,
128
+ }
129
+ return stats
130
+
131
+
132
+ def encode_datetime(values: pd.Series, stats: dict, _: pd.Series | None = None) -> pd.DataFrame:
133
+ # convert
134
+ values = safe_convert_datetime(values)
135
+ values = values.copy()
136
+ # reset index, as `values.mask` can throw errors for misaligned indices
137
+ values.reset_index(drop=True, inplace=True)
138
+ # replace extreme values with randomly sampled 5-th to 10-th largest/smallest values
139
+ min5 = stats["min5"] if len(stats["min5"]) > 0 else [0]
140
+ max5 = stats["max5"] if len(stats["max5"]) > 0 else [0]
141
+ min5 = pd.Series(min5, dtype=values.dtype)
142
+ max5 = pd.Series(max5, dtype=values.dtype)
143
+ values.mask(
144
+ values < min5[0],
145
+ min5.sample(n=len(values), replace=True, ignore_index=True),
146
+ inplace=True,
147
+ )
148
+ values.mask(
149
+ values > max5[0],
150
+ max5.sample(n=len(values), replace=True, ignore_index=True),
151
+ inplace=True,
152
+ )
153
+ # split to sub_columns
154
+ df = split_sub_columns_datetime(values)
155
+ is_not_nan = df["nan"] == 0
156
+ # encode values so that each datetime part ranges from 0 to `max_value-min_value`
157
+ for key in DATETIME_PARTS:
158
+ # subtract minimum value
159
+ df[key] = df[key].where(~is_not_nan, df[key] - stats["min_values"][key])
160
+ # ensure that any value is mapped onto valid value range
161
+ df[key] = np.minimum(df[key], stats["max_values"][key] - stats["min_values"][key])
162
+ df[key] = np.maximum(df[key], 0)
163
+ if not stats["has_nan"]:
164
+ df.drop(["nan"], inplace=True, axis=1)
165
+ if not stats["has_time"]:
166
+ df.drop(["hour", "minute", "second"], inplace=True, axis=1)
167
+ if not stats["has_ms"]:
168
+ df.drop(["ms_E2", "ms_E1", "ms_E0"], inplace=True, axis=1)
169
+ return df
170
+
171
+
172
+ def split_sub_columns_datetime(values: pd.Series) -> pd.DataFrame:
173
+ if not is_date_dtype(values) and not is_timestamp_dtype(values):
174
+ raise ValueError("expected to be datetime")
175
+ values = values.astype("datetime64[us]")
176
+
177
+ # fast datetime part extraction taken from https://stackoverflow.com/a/56260054
178
+ dt = values.to_numpy()
179
+ parts = np.empty(dt.shape + (7,), dtype="u4")
180
+ year, month, day, hour, minute, second = (dt.astype(f"M8[{x}]") for x in "YMDhms")
181
+ parts[:, 0] = year + 1970 # Gregorian Year
182
+ parts[:, 1] = (month - year) + 1 # month
183
+ parts[:, 2] = (day - month) + 1 # dat
184
+ parts[:, 3] = (dt - day).astype("m8[h]") # hour
185
+ parts[:, 4] = (dt - hour).astype("m8[m]") # minute
186
+ parts[:, 5] = (dt - minute).astype("m8[s]") # second
187
+ parts[:, 6] = (dt - second).astype("m8[us]") # microsecond
188
+ # create pd.DataFrame with datetime parts
189
+ sub_columns = {
190
+ "nan": values.reset_index(drop=True).isna(),
191
+ "year": pd.Series(parts[:, 0]),
192
+ "month": pd.Series(parts[:, 1]),
193
+ "day": pd.Series(parts[:, 2]),
194
+ "hour": pd.Series(parts[:, 3]),
195
+ "minute": pd.Series(parts[:, 4]),
196
+ "second": pd.Series(parts[:, 5]),
197
+ "ms_E2": pd.Series(np.floor(parts[:, 6] / 100_000) % 10),
198
+ "ms_E1": pd.Series(np.floor((parts[:, 6] / 10_000) % 10)),
199
+ "ms_E0": pd.Series(np.floor((parts[:, 6] / 1_000) % 10)),
200
+ }
201
+ df = pd.DataFrame(sub_columns)
202
+ df = df.fillna(0)
203
+ df = df.astype("int")
204
+ return df
205
+
206
+
207
+ def decode_datetime(df_encoded: pd.DataFrame, stats: dict):
208
+ # decode y/m/d components
209
+ y = df_encoded["year"] + stats["min_values"]["year"]
210
+ m = df_encoded["month"] + stats["min_values"]["month"]
211
+ d = df_encoded["day"] + stats["min_values"]["day"]
212
+ # fix invalid dates by setting these to last day of month
213
+ is_leap = y.apply(lambda x: calendar.isleap(x))
214
+ d[is_leap & (m == 2) & (d > 29)] = 29
215
+ d[~is_leap & (m == 2) & (d > 28)] = 28
216
+ d[((m == 4) | (m == 6) | (m == 9) | (m == 11)) & (d > 30)] = 30
217
+ # concatenate to datetime string
218
+ y = y.astype(str)
219
+ m = m.astype(str).str.zfill(2)
220
+ d = d.astype(str).str.zfill(2)
221
+ dt_format = "%Y-%m-%d"
222
+ values = y + "-" + m + "-" + d
223
+ if stats["has_time"]:
224
+ hh = (df_encoded["hour"] + stats["min_values"]["hour"]).astype(str).str.zfill(2)
225
+ mm = (df_encoded["minute"] + stats["min_values"]["minute"]).astype(str).str.zfill(2)
226
+ ss = (df_encoded["second"] + stats["min_values"]["second"]).astype(str).str.zfill(2)
227
+ dt_format += " %H:%M:%S"
228
+ values = values + " " + hh + ":" + mm + ":" + ss
229
+ if stats["has_ms"]:
230
+ ms2 = df_encoded["ms_E2"] + stats["min_values"]["ms_E2"]
231
+ ms1 = df_encoded["ms_E1"] + stats["min_values"]["ms_E1"]
232
+ ms0 = df_encoded["ms_E0"] + stats["min_values"]["ms_E0"]
233
+ ms = (100 * ms2 + 10 * ms1 + ms0).astype(str).str.zfill(3)
234
+ dt_format += ".%f"
235
+ values = values + "." + ms
236
+ if "nan" in df_encoded.columns:
237
+ values[df_encoded["nan"] == 1] = pd.NA
238
+ # replace extreme values with randomly sampled 5-th to 10-th largest/smallest values
239
+ if len(stats["min5"]) > 0 and len(stats["max5"]) > 0:
240
+ # format datetime with accordance to the expected unified format when reading from stats
241
+ min5 = [parser.parse(i).strftime(dt_format) for i in stats["min5"]]
242
+ max5 = [parser.parse(i).strftime(dt_format) for i in stats["max5"]]
243
+ is_too_low = values.notna() & (values < min5[0])
244
+ is_too_high = values.notna() & (values > max5[0])
245
+ values.loc[is_too_low] = np.random.choice(min5, size=sum(is_too_low))
246
+ values.loc[is_too_high] = np.random.choice(max5, size=sum(is_too_high))
247
+ elif "nan" in df_encoded.columns:
248
+ # set all values to NaN if no valid values were present
249
+ values[df_encoded["nan"] == 0] = pd.NA
250
+ # convert from string to datetime
251
+ values = pd.to_datetime(values)
252
+ if not stats["has_time"]:
253
+ values = pd.to_datetime(values.dt.date)
254
+ return values