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,162 @@
|
|
|
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 json
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
import warnings
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import pandas as pd
|
|
22
|
+
from pandas.core.dtypes.common import is_datetime64_any_dtype
|
|
23
|
+
from tokenizers.pre_tokenizers import ByteLevel
|
|
24
|
+
|
|
25
|
+
from mostlyai.engine._common import is_sequential, ProgressCallback, ProgressCallbackWrapper, TABLE_COLUMN_INFIX
|
|
26
|
+
from mostlyai.engine._workspace import ensure_workspace_dir, Workspace, reset_dir
|
|
27
|
+
|
|
28
|
+
_LOG = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def format_df(df: pd.DataFrame, columns: list[str], is_target: bool = False) -> pd.DataFrame:
|
|
32
|
+
df = df[columns].copy()
|
|
33
|
+
# Some columns (e.g., SCP columns) may contain np.ndarray, which are not JSON serializable
|
|
34
|
+
# We need to drop them before converting the DataFrame to JSON
|
|
35
|
+
sequential_columns = [col for col in df.columns if is_sequential(df[col])]
|
|
36
|
+
df = df.drop(columns=sequential_columns)
|
|
37
|
+
_LOG.info(f"Formatting {'target' if is_target else 'context'} columns {df.columns.tolist()} to JSON")
|
|
38
|
+
# convert date format to ISO so that it's JSON serializable
|
|
39
|
+
for col in df.columns:
|
|
40
|
+
if is_datetime64_any_dtype(df[col]):
|
|
41
|
+
df[col] = df[col].dt.strftime("%Y-%m-%dT%H:%M:%S")
|
|
42
|
+
return df.apply(lambda x: row_to_json(x, is_target=is_target), axis=1)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def fallback_serializer(obj):
|
|
46
|
+
warnings.warn(f"{type(obj)} is not JSON serializable. Converting it to str(obj) instead.")
|
|
47
|
+
return str(obj)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def row_to_json(row: pd.Series, is_target: bool = False) -> str:
|
|
51
|
+
row_dict = row.to_dict()
|
|
52
|
+
nested_dict = {}
|
|
53
|
+
for key, value in row_dict.items():
|
|
54
|
+
tokens = key.split(TABLE_COLUMN_INFIX, maxsplit=1)
|
|
55
|
+
if len(tokens) == 1:
|
|
56
|
+
table_name = "tgt" # assign a default table name for the target table
|
|
57
|
+
column_name = tokens[0] # columns from the target table
|
|
58
|
+
else: # len(tokens) == 2
|
|
59
|
+
table_name, column_name = tokens # prefixed columns from the context tables
|
|
60
|
+
if table_name not in nested_dict:
|
|
61
|
+
nested_dict[table_name] = {}
|
|
62
|
+
nested_dict[table_name][column_name] = value
|
|
63
|
+
if is_target:
|
|
64
|
+
# assume there is at most one item in nested_dict when is_target=True
|
|
65
|
+
nested_dict = next(iter(nested_dict.values()), {})
|
|
66
|
+
# The leading space is required to avoid inconsistent tokenization results during training and generation
|
|
67
|
+
# This ensures that '{' of the context and target json are tokenized into the same thing
|
|
68
|
+
return " " + json.dumps(
|
|
69
|
+
nested_dict,
|
|
70
|
+
ensure_ascii=False,
|
|
71
|
+
separators=None,
|
|
72
|
+
indent=None,
|
|
73
|
+
default=fallback_serializer,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def encode_df(
|
|
78
|
+
ctx_df: pd.DataFrame,
|
|
79
|
+
ctx_columns: list[str],
|
|
80
|
+
tgt_df: pd.DataFrame | None = None,
|
|
81
|
+
tgt_columns: list[str] | None = None,
|
|
82
|
+
) -> pd.DataFrame:
|
|
83
|
+
assert (tgt_df is None) == (tgt_columns is None), "tgt_df and tgt_columns must be both None or both not None"
|
|
84
|
+
df = pd.DataFrame()
|
|
85
|
+
df["ctx"] = format_df(ctx_df, columns=ctx_columns, is_target=False)
|
|
86
|
+
if tgt_df is not None and tgt_columns is not None:
|
|
87
|
+
df["tgt"] = format_df(tgt_df, columns=tgt_columns, is_target=True)
|
|
88
|
+
|
|
89
|
+
# log the bounds of n_tokens in this partition
|
|
90
|
+
content = df["ctx"] + df["tgt"] if "tgt" in df.columns else df["ctx"]
|
|
91
|
+
n_chars_stats = content.apply(lambda x: len(x)).describe(percentiles=[0.5]).rename("#chars")
|
|
92
|
+
pretokenizer = ByteLevel(add_prefix_space=True, use_regex=True)
|
|
93
|
+
n_pretokens_stats = (
|
|
94
|
+
content.apply(lambda x: len(pretokenizer.pre_tokenize_str(x))).describe(percentiles=[0.5]).rename("#pretokens")
|
|
95
|
+
)
|
|
96
|
+
stats = pd.concat([n_pretokens_stats, n_chars_stats], axis=1).loc[["min", "50%", "max"], :]
|
|
97
|
+
_LOG.info(f"token statistics of this partition: \n{stats}")
|
|
98
|
+
return df
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _encode_partition(
|
|
102
|
+
*,
|
|
103
|
+
tgt_partition_file: Path,
|
|
104
|
+
tgt_stats: dict,
|
|
105
|
+
output_path: Path,
|
|
106
|
+
ctx_partition_file: Path | None = None,
|
|
107
|
+
ctx_stats: dict | None = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
tgt_df = pd.read_parquet(tgt_partition_file)
|
|
110
|
+
tgt_columns = list(tgt_stats.get("columns", {}).keys())
|
|
111
|
+
if ctx_partition_file:
|
|
112
|
+
ctx_df = pd.read_parquet(ctx_partition_file)
|
|
113
|
+
ctx_columns = list(ctx_stats.get("columns", {}).keys())
|
|
114
|
+
else:
|
|
115
|
+
# create on-the-fly context
|
|
116
|
+
ctx_df = pd.DataFrame(index=range(len(tgt_df)))
|
|
117
|
+
ctx_columns = []
|
|
118
|
+
df = encode_df(
|
|
119
|
+
ctx_df=ctx_df,
|
|
120
|
+
ctx_columns=ctx_columns,
|
|
121
|
+
tgt_df=tgt_df,
|
|
122
|
+
tgt_columns=tgt_columns,
|
|
123
|
+
)
|
|
124
|
+
# shuffle and persist to disk as parquet files
|
|
125
|
+
df = df.sample(frac=1)
|
|
126
|
+
df.to_parquet(output_path / tgt_partition_file.name, engine="pyarrow", index=False)
|
|
127
|
+
_LOG.info(f"encoded partition {tgt_partition_file.name} {df.shape}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def encode(workspace_dir: str | Path | None = None, update_progress: ProgressCallback | None = None) -> None:
|
|
131
|
+
_LOG.info("ENCODE_LANGUAGE started")
|
|
132
|
+
t0 = time.time()
|
|
133
|
+
with ProgressCallbackWrapper(update_progress) as progress:
|
|
134
|
+
workspace_dir = ensure_workspace_dir(workspace_dir)
|
|
135
|
+
workspace = Workspace(workspace_dir)
|
|
136
|
+
reset_dir(workspace.encoded_data_val.path)
|
|
137
|
+
reset_dir(workspace.encoded_data_trn.path)
|
|
138
|
+
reset_dir(workspace.encoded_data_path)
|
|
139
|
+
has_context = workspace.ctx_data_path.exists()
|
|
140
|
+
|
|
141
|
+
tgt_stats = workspace.tgt_stats.read()
|
|
142
|
+
tgt_pqt_partitions = workspace.tgt_data.fetch_all()
|
|
143
|
+
|
|
144
|
+
if has_context:
|
|
145
|
+
ctx_stats = workspace.ctx_stats.read()
|
|
146
|
+
ctx_pqt_partitions = workspace.ctx_data.fetch_all()
|
|
147
|
+
if len(tgt_pqt_partitions) != len(ctx_pqt_partitions):
|
|
148
|
+
raise RuntimeError("partition files for tgt and ctx do not match")
|
|
149
|
+
else:
|
|
150
|
+
ctx_stats = None
|
|
151
|
+
ctx_pqt_partitions = None
|
|
152
|
+
|
|
153
|
+
for i in range(len(tgt_pqt_partitions)):
|
|
154
|
+
_encode_partition(
|
|
155
|
+
tgt_partition_file=tgt_pqt_partitions[i],
|
|
156
|
+
tgt_stats=tgt_stats,
|
|
157
|
+
output_path=workspace.encoded_data_path,
|
|
158
|
+
ctx_partition_file=ctx_pqt_partitions[i] if has_context else None,
|
|
159
|
+
ctx_stats=ctx_stats if has_context else None,
|
|
160
|
+
)
|
|
161
|
+
progress.update(completed=i, total=len(tgt_pqt_partitions) + 1)
|
|
162
|
+
_LOG.info(f"ENCODE_LANGUAGE finished in {time.time() - t0:.2f}s")
|
|
@@ -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,51 @@
|
|
|
1
|
+
# Copyright 2025 MOSTLY AI
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from abc import abstractmethod, ABC
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
|
|
19
|
+
from formatron.formatter import FormatterBuilder
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class EngineMetrics:
|
|
24
|
+
tokenize_time: float
|
|
25
|
+
generate_time: float
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LanguageEngine(ABC):
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def initialize_logits_processors(
|
|
31
|
+
self, formatter_builders: list[FormatterBuilder], vocab_processors: list[Callable] | None = None
|
|
32
|
+
):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def generate(
|
|
37
|
+
self, text: list[str], sampling_temperature: float, sampling_top_p: float
|
|
38
|
+
) -> tuple[list[int], EngineMetrics]:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def get_default_batch_size(self) -> int:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def supports_json_enforcing(self) -> bool:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def cleanup(self):
|
|
51
|
+
pass
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Copyright 2025 MOSTLY AI
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from os import PathLike
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import torch
|
|
21
|
+
from formatron.integrations.transformers import create_formatter_logits_processor_list
|
|
22
|
+
from peft import PeftModel
|
|
23
|
+
|
|
24
|
+
from transformers import AutoTokenizer
|
|
25
|
+
from mostlyai.engine._language.common import load_base_model_and_config
|
|
26
|
+
from mostlyai.engine._language.tokenizer_utils import tokenize_fn
|
|
27
|
+
from mostlyai.engine._language.formatron_utils import monkey_patch_formatron
|
|
28
|
+
|
|
29
|
+
from mostlyai.engine._language.engine.base import EngineMetrics, LanguageEngine
|
|
30
|
+
from formatron.formatter import FormatterBuilder
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HuggingFaceEngine(LanguageEngine):
|
|
34
|
+
def __init__(
|
|
35
|
+
self, model_path: PathLike | str, device: torch.device, max_new_tokens: int, tokenizer_max_length: int
|
|
36
|
+
):
|
|
37
|
+
self.device = device
|
|
38
|
+
self.max_new_tokens = max_new_tokens
|
|
39
|
+
self.tokenizer_max_length = tokenizer_max_length
|
|
40
|
+
|
|
41
|
+
is_peft_adapter = (Path(model_path) / "adapter_config.json").exists()
|
|
42
|
+
model_path = str(model_path)
|
|
43
|
+
self._model, _ = load_base_model_and_config(
|
|
44
|
+
model_path, device=device, is_peft_adapter=is_peft_adapter, is_training=False
|
|
45
|
+
)
|
|
46
|
+
if is_peft_adapter:
|
|
47
|
+
self._model = PeftModel.from_pretrained(self._model, model_path, is_trainable=False)
|
|
48
|
+
self._model = self._model.merge_and_unload()
|
|
49
|
+
self._default_batch_size = 64
|
|
50
|
+
else:
|
|
51
|
+
# only the LSTM model does not have an adapter
|
|
52
|
+
self._default_batch_size = 128
|
|
53
|
+
|
|
54
|
+
self.tokenizer = AutoTokenizer.from_pretrained(
|
|
55
|
+
model_path,
|
|
56
|
+
padding_side="left",
|
|
57
|
+
truncation_side="left",
|
|
58
|
+
legacy=True,
|
|
59
|
+
# these must be False at initialization, as we manually add them later in tokenize_fn
|
|
60
|
+
add_bos_token=False,
|
|
61
|
+
add_eos_token=False,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# we can't enforce JSON output if LSTM tokenizer training was skipped
|
|
65
|
+
is_trained_lstm_tokenizer = not is_peft_adapter and self.tokenizer.vocab_size > len(
|
|
66
|
+
self.tokenizer.special_tokens_map
|
|
67
|
+
)
|
|
68
|
+
self._json_enforcing_possible = is_peft_adapter or is_trained_lstm_tokenizer
|
|
69
|
+
|
|
70
|
+
# apply all necessary monkey patches to the formatron library
|
|
71
|
+
if self._json_enforcing_possible:
|
|
72
|
+
monkey_patch_formatron()
|
|
73
|
+
|
|
74
|
+
self._logits_processors = None
|
|
75
|
+
|
|
76
|
+
def get_default_batch_size(self) -> int:
|
|
77
|
+
return self._default_batch_size
|
|
78
|
+
|
|
79
|
+
def supports_json_enforcing(self) -> bool:
|
|
80
|
+
return self._json_enforcing_possible
|
|
81
|
+
|
|
82
|
+
def initialize_logits_processors(
|
|
83
|
+
self, formatter_builders: list[FormatterBuilder], vocab_processors: list[Callable] | None = None
|
|
84
|
+
):
|
|
85
|
+
self._logits_processors = create_formatter_logits_processor_list(
|
|
86
|
+
tokenizer=self.tokenizer, formatter_builders=formatter_builders, vocab_processors=vocab_processors
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def generate(
|
|
90
|
+
self, text: list[str], sampling_temperature: float, sampling_top_p: float
|
|
91
|
+
) -> tuple[list[int], EngineMetrics]:
|
|
92
|
+
do_sample = sampling_temperature > 0.0
|
|
93
|
+
|
|
94
|
+
tokenize_kwargs = dict(
|
|
95
|
+
tokenizer=self.tokenizer,
|
|
96
|
+
return_tensors="pt",
|
|
97
|
+
add_bos_token=True,
|
|
98
|
+
add_eos_token=False,
|
|
99
|
+
padding=True,
|
|
100
|
+
truncation=True,
|
|
101
|
+
max_length=self.tokenizer_max_length, # truncates input
|
|
102
|
+
)
|
|
103
|
+
t_tokenize = time.time()
|
|
104
|
+
inputs = tokenize_fn(text=text, **tokenize_kwargs).to(self.device)
|
|
105
|
+
tokenize_time = time.time() - t_tokenize
|
|
106
|
+
|
|
107
|
+
generate_kwargs = dict(
|
|
108
|
+
do_sample=do_sample,
|
|
109
|
+
max_new_tokens=self.max_new_tokens,
|
|
110
|
+
temperature=sampling_temperature if do_sample else None,
|
|
111
|
+
top_p=sampling_top_p if do_sample else None,
|
|
112
|
+
bos_token_id=self.tokenizer.bos_token_id,
|
|
113
|
+
pad_token_id=self.tokenizer.eos_token_id, # must be eos or will get ValueError from formatron
|
|
114
|
+
eos_token_id=self.tokenizer.eos_token_id,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if self._logits_processors is not None:
|
|
118
|
+
# number of formatters must match the batch size, batch size is always reduced so this is fine
|
|
119
|
+
actual_batch_size = len(inputs["input_ids"])
|
|
120
|
+
self._logits_processors[0]._formatters = self._logits_processors[0]._formatters[:actual_batch_size]
|
|
121
|
+
|
|
122
|
+
t_generate = time.time()
|
|
123
|
+
outputs = self._model.generate(**inputs, **generate_kwargs, logits_processor=self._logits_processors)
|
|
124
|
+
generate_time = time.time() - t_generate
|
|
125
|
+
|
|
126
|
+
if self._logits_processors:
|
|
127
|
+
self._logits_processors[0].reset()
|
|
128
|
+
|
|
129
|
+
_, input_length = inputs["input_ids"].shape
|
|
130
|
+
# truncate the prompt from the outputs
|
|
131
|
+
outputs = outputs[:, input_length:]
|
|
132
|
+
metrics = EngineMetrics(tokenize_time=tokenize_time, generate_time=generate_time)
|
|
133
|
+
return outputs.detach().cpu().tolist(), metrics
|
|
134
|
+
|
|
135
|
+
def cleanup(self):
|
|
136
|
+
pass
|
|
@@ -0,0 +1,187 @@
|
|
|
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 contextlib
|
|
16
|
+
from os import PathLike
|
|
17
|
+
import time
|
|
18
|
+
import typing
|
|
19
|
+
from mostlyai.engine._language.engine.base import EngineMetrics, LanguageEngine
|
|
20
|
+
import torch
|
|
21
|
+
from formatron.config import EngineGenerationConfig
|
|
22
|
+
from formatron.formatter import FormatterBuilder
|
|
23
|
+
|
|
24
|
+
from peft import PeftConfig
|
|
25
|
+
|
|
26
|
+
from transformers import AutoTokenizer, AutoConfig, PreTrainedTokenizerBase
|
|
27
|
+
from vllm import LLM, SamplingParams
|
|
28
|
+
from vllm.lora.request import LoRARequest
|
|
29
|
+
from vllm.config import _get_and_verify_max_len
|
|
30
|
+
from mostlyai.engine._language.tokenizer_utils import tokenize_fn
|
|
31
|
+
from formatron.integrations.vllm import create_engine_vocabulary, FormattersLogitsProcessor
|
|
32
|
+
from vllm.distributed import destroy_model_parallel, destroy_distributed_environment
|
|
33
|
+
|
|
34
|
+
import gc
|
|
35
|
+
from vllm.platforms import current_platform
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cleanup_dist_env_and_memory():
|
|
39
|
+
"""Copy from current main of vllm replace by import when possible"""
|
|
40
|
+
destroy_model_parallel()
|
|
41
|
+
destroy_distributed_environment()
|
|
42
|
+
with contextlib.suppress(AssertionError):
|
|
43
|
+
torch.distributed.destroy_process_group()
|
|
44
|
+
gc.collect()
|
|
45
|
+
if not current_platform.is_cpu():
|
|
46
|
+
torch.cuda.empty_cache()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def create_vllm_logits_processors(
|
|
50
|
+
llm: LLM,
|
|
51
|
+
formatter_builders: typing.Sequence[FormatterBuilder | None],
|
|
52
|
+
configs: typing.Sequence[EngineGenerationConfig] = None,
|
|
53
|
+
vocab_processors: list[typing.Callable] | None = None,
|
|
54
|
+
) -> list[FormattersLogitsProcessor]:
|
|
55
|
+
"""
|
|
56
|
+
Create list of formatter logits processors without creating engine vocabulary several times.
|
|
57
|
+
"""
|
|
58
|
+
tokenizer = llm.get_tokenizer()
|
|
59
|
+
vocab = create_engine_vocabulary(tokenizer, vocab_processors)
|
|
60
|
+
formatters = [
|
|
61
|
+
i.build(vocab, lambda tokens: tokenizer.decode(tokens)) if i is not None else None for i in formatter_builders
|
|
62
|
+
]
|
|
63
|
+
return [FormattersLogitsProcessor([formatter], tokenizer.eos_token_id, configs) for formatter in formatters]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class MaskInvalidIndicesLogitsProcessor:
|
|
67
|
+
"""
|
|
68
|
+
Certain models have output size greater than their vocabulary size.
|
|
69
|
+
This logits processor masks the output indices that do not correspond
|
|
70
|
+
to a token id.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, tokenizer: PreTrainedTokenizerBase): # : PretrainedTokenizer
|
|
74
|
+
self.mask: torch.Tensor | None = None
|
|
75
|
+
self.valid_token_ids = torch.tensor(list(tokenizer.vocab.values()))
|
|
76
|
+
|
|
77
|
+
def __call__(self, input_ids: list[int], scores: torch.Tensor) -> torch.Tensor:
|
|
78
|
+
if self.mask is None:
|
|
79
|
+
ninf = float("-inf")
|
|
80
|
+
self.mask = torch.full_like(scores, ninf)
|
|
81
|
+
self.mask[..., self.valid_token_ids] = 0
|
|
82
|
+
scores = scores + self.mask
|
|
83
|
+
return scores
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class VLLMEngine(LanguageEngine):
|
|
87
|
+
def __init__(
|
|
88
|
+
self, model_path: PathLike | str, device: torch.device, max_new_tokens: int, tokenizer_max_length: int
|
|
89
|
+
):
|
|
90
|
+
self.device = device
|
|
91
|
+
self.tokenizer_max_length = tokenizer_max_length
|
|
92
|
+
self.max_new_tokens = max_new_tokens
|
|
93
|
+
|
|
94
|
+
peft_config = PeftConfig.from_pretrained(model_path)
|
|
95
|
+
base_config = AutoConfig.from_pretrained(peft_config.base_model_name_or_path)
|
|
96
|
+
|
|
97
|
+
model_path = str(model_path)
|
|
98
|
+
self._lora_request = LoRARequest("adapter", 1, model_path)
|
|
99
|
+
config_max_model_len = _get_and_verify_max_len(
|
|
100
|
+
base_config, max_model_len=None, disable_sliding_window=None, sliding_window_len=None
|
|
101
|
+
)
|
|
102
|
+
self.llm = LLM(
|
|
103
|
+
model=peft_config.base_model_name_or_path,
|
|
104
|
+
tokenizer=model_path,
|
|
105
|
+
device=device.type,
|
|
106
|
+
max_model_len=min(config_max_model_len, self.tokenizer_max_length + max_new_tokens),
|
|
107
|
+
enable_lora=True,
|
|
108
|
+
# enforce_eager=True, # results in big slowdown, but is needed when running pytest locally
|
|
109
|
+
swap_space=0,
|
|
110
|
+
)
|
|
111
|
+
self._base_logits_processors = [MaskInvalidIndicesLogitsProcessor(self.llm.get_tokenizer())]
|
|
112
|
+
self.tokenizer = AutoTokenizer.from_pretrained(
|
|
113
|
+
model_path,
|
|
114
|
+
padding_side="left",
|
|
115
|
+
truncation_side="left",
|
|
116
|
+
legacy=True,
|
|
117
|
+
# these must be False at initialization, as we manually add them later in tokenize_fn
|
|
118
|
+
add_bos_token=False,
|
|
119
|
+
add_eos_token=False,
|
|
120
|
+
)
|
|
121
|
+
self._logits_processors = None
|
|
122
|
+
|
|
123
|
+
def get_default_batch_size(self) -> int:
|
|
124
|
+
return 192
|
|
125
|
+
|
|
126
|
+
def supports_json_enforcing(self) -> bool:
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
def initialize_logits_processors(
|
|
130
|
+
self, formatter_builders: list[FormatterBuilder], vocab_processors: list[typing.Callable] | None = None
|
|
131
|
+
):
|
|
132
|
+
self._logits_processors = create_vllm_logits_processors(
|
|
133
|
+
llm=self.llm, formatter_builders=formatter_builders, configs=None, vocab_processors=vocab_processors
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def generate(
|
|
137
|
+
self, text: list[str], sampling_temperature: float, sampling_top_p: float
|
|
138
|
+
) -> tuple[list[int], EngineMetrics]:
|
|
139
|
+
tokenize_kwargs = dict(
|
|
140
|
+
tokenizer=self.tokenizer,
|
|
141
|
+
return_tensors=None,
|
|
142
|
+
add_bos_token=True,
|
|
143
|
+
add_eos_token=False,
|
|
144
|
+
padding=False,
|
|
145
|
+
truncation=True,
|
|
146
|
+
max_length=self.tokenizer_max_length, # truncates input
|
|
147
|
+
)
|
|
148
|
+
t_tokenize = time.time()
|
|
149
|
+
inputs = tokenize_fn(text=text, **tokenize_kwargs)
|
|
150
|
+
tokenize_time = time.time() - t_tokenize
|
|
151
|
+
|
|
152
|
+
actual_batch_size = len(inputs["input_ids"])
|
|
153
|
+
if self._logits_processors is not None:
|
|
154
|
+
sampling_params = [
|
|
155
|
+
SamplingParams(
|
|
156
|
+
max_tokens=self.max_new_tokens,
|
|
157
|
+
temperature=sampling_temperature,
|
|
158
|
+
top_p=sampling_top_p,
|
|
159
|
+
logits_processors=[lp, *self._base_logits_processors],
|
|
160
|
+
)
|
|
161
|
+
for lp in self._logits_processors[:actual_batch_size]
|
|
162
|
+
]
|
|
163
|
+
else:
|
|
164
|
+
sampling_params = SamplingParams(
|
|
165
|
+
max_tokens=self.max_new_tokens,
|
|
166
|
+
temperature=sampling_temperature,
|
|
167
|
+
top_p=sampling_top_p,
|
|
168
|
+
logits_processors=self._base_logits_processors,
|
|
169
|
+
)
|
|
170
|
+
t_generate = time.time()
|
|
171
|
+
outputs = self.llm.generate(
|
|
172
|
+
prompts=None,
|
|
173
|
+
prompt_token_ids=inputs["input_ids"],
|
|
174
|
+
sampling_params=sampling_params,
|
|
175
|
+
use_tqdm=False,
|
|
176
|
+
lora_request=self._lora_request,
|
|
177
|
+
)
|
|
178
|
+
generate_time = time.time() - t_generate
|
|
179
|
+
if self._logits_processors is not None:
|
|
180
|
+
for lp in self._logits_processors:
|
|
181
|
+
lp.reset()
|
|
182
|
+
metrics = EngineMetrics(tokenize_time=tokenize_time, generate_time=generate_time)
|
|
183
|
+
return [r.outputs[0].token_ids for r in outputs], metrics
|
|
184
|
+
|
|
185
|
+
def cleanup(self):
|
|
186
|
+
del self.llm
|
|
187
|
+
cleanup_dist_env_and_memory()
|
|
@@ -0,0 +1,114 @@
|
|
|
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 typing
|
|
16
|
+
|
|
17
|
+
import pandas as pd
|
|
18
|
+
from formatron.schemas.pydantic import ClassSchema
|
|
19
|
+
from json import JSONDecodeError
|
|
20
|
+
from pydantic import ValidationError
|
|
21
|
+
from formatron.formatter import FormatterBuilder
|
|
22
|
+
from typing import Literal
|
|
23
|
+
from formatron.formats import json
|
|
24
|
+
from pydantic import create_model
|
|
25
|
+
from transformers import PreTrainedTokenizerBase
|
|
26
|
+
|
|
27
|
+
JSON_NULL = "null"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def prepare_seed_for_formatron(sample_seed: pd.DataFrame, tokenizer: PreTrainedTokenizerBase) -> pd.DataFrame:
|
|
31
|
+
def transform(x: str | None) -> str:
|
|
32
|
+
if pd.isna(x):
|
|
33
|
+
null = tokenizer.decode(tokenizer.encode(JSON_NULL), skip_special_tokens=True)
|
|
34
|
+
# formatron needs to be able to express JSON_NULL with available vocabulary
|
|
35
|
+
# if that's the case, harmonize null-like values to None (e.g. pd.NA would cause formatron to fail)
|
|
36
|
+
# otherwise, fallback to empty string
|
|
37
|
+
return None if null == JSON_NULL else ""
|
|
38
|
+
# skip tokens unseen during training
|
|
39
|
+
return tokenizer.decode(tokenizer.encode(x), skip_special_tokens=True)
|
|
40
|
+
|
|
41
|
+
return sample_seed.astype("string[pyarrow]").map(transform)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def monkey_patch_formatron():
|
|
45
|
+
# alter the Grammar of formatron's json schema
|
|
46
|
+
FORMATRON_WHITESPACE_MAX_REPETITIONS = 10
|
|
47
|
+
SPACE_NONTERMINAL = f"[ \t\n\r]{{0,{FORMATRON_WHITESPACE_MAX_REPETITIONS}}}"
|
|
48
|
+
|
|
49
|
+
json.GRAMMAR_HEADER = rf"""integer ::= #"-?(0|[1-9]\\d*)";
|
|
50
|
+
number ::= #"-?(0|[1-9]\\d*)(\\.\\d+)?([eE][+-]?\\d+)?";
|
|
51
|
+
string ::= #'"([^\\\\"\u0000-\u001f]|\\\\["\\\\bfnrt/]|\\\\u[0-9A-Fa-f]{{4}})*"';
|
|
52
|
+
boolean ::= "true"|"false";
|
|
53
|
+
null ::= "null";
|
|
54
|
+
array ::= array_begin (json_value (comma json_value)*)? array_end;
|
|
55
|
+
object ::= object_begin (string colon json_value (comma string colon json_value)*)? object_end;
|
|
56
|
+
json_value ::= number|string|boolean|null|array|object;
|
|
57
|
+
comma ::= #"{SPACE_NONTERMINAL},{SPACE_NONTERMINAL}";
|
|
58
|
+
colon ::= #"{SPACE_NONTERMINAL}:{SPACE_NONTERMINAL}";
|
|
59
|
+
object_begin ::= #" \\{{{SPACE_NONTERMINAL}";
|
|
60
|
+
object_end ::= #"{SPACE_NONTERMINAL}\\}}";
|
|
61
|
+
array_begin ::= #"\\[{SPACE_NONTERMINAL}";
|
|
62
|
+
array_end ::= #"{SPACE_NONTERMINAL}\\]";
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_formatter_builders(
|
|
67
|
+
*, seed_df: pd.DataFrame | None = None, size: int | None = None, unseeded_fields: list[str]
|
|
68
|
+
) -> list[FormatterBuilder]:
|
|
69
|
+
assert (seed_df is not None) ^ (size is not None), "exactly one of seed_df or size must be provided"
|
|
70
|
+
formatter_builders = []
|
|
71
|
+
if seed_df is None:
|
|
72
|
+
seed_df = pd.DataFrame(index=range(size))
|
|
73
|
+
for _, seed_row in seed_df.iterrows():
|
|
74
|
+
formatter_builder = FormatterBuilder()
|
|
75
|
+
model_dict = {}
|
|
76
|
+
if not seed_row.empty:
|
|
77
|
+
model_dict |= {field_name: (Literal[seed_value], ...) for field_name, seed_value in seed_row.items()}
|
|
78
|
+
model_dict |= {field_name: (str, ...) for field_name in unseeded_fields}
|
|
79
|
+
schema = create_model("TargetModel", **model_dict, __base__=MostlyClassSchema)
|
|
80
|
+
formatter_builder.append_str(f"{formatter_builder.json(schema, capture_name=None)}")
|
|
81
|
+
formatter_builders.append(formatter_builder)
|
|
82
|
+
return formatter_builders
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_vocab_processors(is_peft_adapter: bool) -> list[typing.Callable] | None:
|
|
86
|
+
if not is_peft_adapter:
|
|
87
|
+
|
|
88
|
+
def update_vocab_lstm(token_to_char: dict[bytes, bytes]):
|
|
89
|
+
"""
|
|
90
|
+
Maps special tokens ("▁", "␊") back to their original representation (" ", "\n")
|
|
91
|
+
(used in LSTM tokenizer)
|
|
92
|
+
"""
|
|
93
|
+
token_to_char["\u2581".encode()] = b" " # "▁" -> " "
|
|
94
|
+
token_to_char["\u240a".encode()] = b"\n" # "␊" -> "\n"
|
|
95
|
+
|
|
96
|
+
return [update_vocab_lstm]
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class MostlyClassSchema(ClassSchema):
|
|
101
|
+
@classmethod
|
|
102
|
+
def from_json(cls, _json: str) -> "MostlyClassSchema":
|
|
103
|
+
"""
|
|
104
|
+
Create a MostlyClassSchema from a JSON string.
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
return cls.model_validate_json(_json)
|
|
108
|
+
except ValidationError as e:
|
|
109
|
+
for error in e.errors():
|
|
110
|
+
if error["type"] == "json_invalid":
|
|
111
|
+
raise JSONDecodeError(
|
|
112
|
+
f"Caught pydantic ValidationError {e}, reraising as JSONDecodeError", _json, 0
|
|
113
|
+
)
|
|
114
|
+
raise e
|