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