mostlyai-engine 1.1.11__tar.gz → 1.2.0__tar.gz

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 (51) hide show
  1. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/PKG-INFO +11 -12
  2. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/__init__.py +1 -1
  3. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_common.py +1 -1
  4. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_language/engine/base.py +4 -6
  5. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_language/engine/hf_engine.py +72 -29
  6. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_language/engine/vllm_engine.py +89 -67
  7. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_language/generation.py +13 -16
  8. mostlyai_engine-1.2.0/mostlyai/engine/_language/xgrammar_utils.py +147 -0
  9. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/pyproject.toml +24 -22
  10. mostlyai_engine-1.1.11/mostlyai/engine/_language/formatron_utils.py +0 -264
  11. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/.gitignore +0 -0
  12. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/LICENSE +0 -0
  13. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/README.md +0 -0
  14. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_dtypes.py +0 -0
  15. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_encoding_types/__init__.py +0 -0
  16. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_encoding_types/language/__init__.py +0 -0
  17. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_encoding_types/language/categorical.py +0 -0
  18. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_encoding_types/language/datetime.py +0 -0
  19. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_encoding_types/language/numeric.py +0 -0
  20. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_encoding_types/language/text.py +0 -0
  21. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_encoding_types/tabular/__init__.py +0 -0
  22. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_encoding_types/tabular/categorical.py +0 -0
  23. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_encoding_types/tabular/character.py +0 -0
  24. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_encoding_types/tabular/datetime.py +0 -0
  25. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_encoding_types/tabular/itt.py +0 -0
  26. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_encoding_types/tabular/lat_long.py +0 -0
  27. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_encoding_types/tabular/numeric.py +0 -0
  28. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_language/__init__.py +0 -0
  29. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_language/common.py +0 -0
  30. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_language/encoding.py +0 -0
  31. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_language/engine/__init__.py +0 -0
  32. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_language/lstm.py +0 -0
  33. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_language/tokenizer_utils.py +0 -0
  34. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_language/training.py +0 -0
  35. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_memory.py +0 -0
  36. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_tabular/__init__.py +0 -0
  37. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_tabular/argn.py +0 -0
  38. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_tabular/common.py +0 -0
  39. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_tabular/encoding.py +0 -0
  40. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_tabular/fairness.py +0 -0
  41. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_tabular/generation.py +0 -0
  42. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_tabular/training.py +0 -0
  43. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_training_utils.py +0 -0
  44. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/_workspace.py +0 -0
  45. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/analysis.py +0 -0
  46. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/domain.py +0 -0
  47. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/encoding.py +0 -0
  48. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/generation.py +0 -0
  49. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/logging.py +0 -0
  50. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/splitting.py +0 -0
  51. {mostlyai_engine-1.1.11 → mostlyai_engine-1.2.0}/mostlyai/engine/training.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mostlyai-engine
3
- Version: 1.1.11
3
+ Version: 1.2.0
4
4
  Summary: Synthetic Data Engine
5
5
  Project-URL: homepage, https://github.com/mostly-ai/mostlyai-engine
6
6
  Project-URL: repository, https://github.com/mostly-ai/mostlyai-engine
@@ -24,26 +24,25 @@ Classifier: Programming Language :: Python :: 3.13
24
24
  Classifier: Topic :: Software Development :: Libraries
25
25
  Classifier: Typing :: Typed
26
26
  Requires-Python: >=3.10
27
- Requires-Dist: accelerate>=0.31.0
28
- Requires-Dist: datasets<4,>=3.0.0
29
- Requires-Dist: formatron<0.5,>=0.4.11
30
- Requires-Dist: huggingface-hub<0.29,>=0.28.0
31
- Requires-Dist: joblib<2,>=1.3.0
27
+ Requires-Dist: accelerate>=1.5.0
28
+ Requires-Dist: datasets>=3.0.0
29
+ Requires-Dist: huggingface-hub>=0.30.0
30
+ Requires-Dist: joblib>=1.4.2
32
31
  Requires-Dist: json-repair>=0.30.0
33
- Requires-Dist: numpy<2,>=1.26.3
34
- Requires-Dist: opacus<2,>=1.5.2
32
+ Requires-Dist: numpy>=1.26.3
33
+ Requires-Dist: opacus>=1.5.2
35
34
  Requires-Dist: pandas~=2.2.0
36
35
  Requires-Dist: peft>=0.12.0
37
36
  Requires-Dist: psutil<6,>=5.9.5
38
37
  Requires-Dist: pyarrow>=16.0.0
39
- Requires-Dist: rich<14,>=13.9.4
40
38
  Requires-Dist: setuptools<76,>=75.0.0
41
- Requires-Dist: tokenizers<0.22,>=0.21.0
39
+ Requires-Dist: tokenizers>=0.21.0
42
40
  Requires-Dist: torch==2.6.0; sys_platform == 'linux'
43
41
  Requires-Dist: torch>=2.6.0; sys_platform == 'darwin'
44
42
  Requires-Dist: torchaudio==2.6.0; sys_platform == 'linux'
45
43
  Requires-Dist: torchvision==0.21.0; sys_platform == 'linux'
46
- Requires-Dist: transformers<5,>=4.45.2
44
+ Requires-Dist: transformers>=4.51.0
45
+ Requires-Dist: xgrammar==0.1.17
47
46
  Provides-Extra: cpu
48
47
  Requires-Dist: torch==2.6.0+cpu; (sys_platform == 'linux') and extra == 'cpu'
49
48
  Requires-Dist: torch==2.6.0; (sys_platform != 'linux') and extra == 'cpu'
@@ -56,7 +55,7 @@ Requires-Dist: torch==2.6.0; extra == 'gpu'
56
55
  Requires-Dist: torchaudio==2.6.0; extra == 'gpu'
57
56
  Requires-Dist: torchvision==0.21.0; extra == 'gpu'
58
57
  Requires-Dist: vllm<0.9,>=0.7.3; (sys_platform == 'darwin') and extra == 'gpu'
59
- Requires-Dist: vllm<0.9,>=0.8.1; (sys_platform == 'linux') and extra == 'gpu'
58
+ Requires-Dist: vllm<0.9,>=0.8.2; (sys_platform == 'linux') and extra == 'gpu'
60
59
  Description-Content-Type: text/markdown
61
60
 
62
61
  # Synthetic Data Engine 💎
@@ -22,7 +22,7 @@ from mostlyai.engine.generation import generate
22
22
 
23
23
 
24
24
  __all__ = ["split", "analyze", "encode", "train", "generate", "init_logging"]
25
- __version__ = "1.1.11"
25
+ __version__ = "1.2.0"
26
26
 
27
27
  # suppress specific warning related to os.fork() in multi-threaded processes
28
28
  warnings.filterwarnings("ignore", category=DeprecationWarning, message=".*multi-threaded.*fork.*")
@@ -511,7 +511,7 @@ def skip_if_error(func: Callable) -> Callable:
511
511
 
512
512
 
513
513
  def encode_slen_sidx_sdec(vals: pd.Series, max_seq_len: int, prefix: str = "") -> pd.DataFrame:
514
- assert vals.dtype == int or vals.dtype == "int64[pyarrow]"
514
+ assert is_integer_dtype(vals)
515
515
  if max_seq_len < SLEN_SIDX_DIGIT_ENCODING_THRESHOLD or prefix == SDEC_SUB_COLUMN_PREFIX:
516
516
  # encode slen and sidx as numeric_discrete
517
517
  df = pd.DataFrame({f"{prefix}cat": vals})
@@ -12,11 +12,11 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
+ from abc import ABC, abstractmethod
16
+ from collections.abc import Generator
15
17
  from dataclasses import dataclass
16
- from abc import abstractmethod, ABC
17
- from collections.abc import Callable
18
18
 
19
- from formatron.formatter import FormatterBuilder
19
+ from pydantic import BaseModel
20
20
 
21
21
 
22
22
  @dataclass
@@ -27,9 +27,7 @@ class EngineMetrics:
27
27
 
28
28
  class LanguageEngine(ABC):
29
29
  @abstractmethod
30
- def initialize_logits_processors(
31
- self, formatter_builders: list[FormatterBuilder], vocab_processors: list[Callable] | None = None
32
- ):
30
+ def initialize_logits_processors(self, schemas: Generator[BaseModel]):
33
31
  pass
34
32
 
35
33
  @abstractmethod
@@ -12,22 +12,73 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
- from os import PathLike
15
+ from __future__ import annotations
16
+
16
17
  import time
17
- from collections.abc import Callable
18
+ from collections.abc import Generator
19
+ from os import PathLike
18
20
  from pathlib import Path
19
21
 
20
22
  import torch
21
- from formatron.integrations.transformers import create_formatter_logits_processor_list
23
+ import transformers
24
+ import xgrammar as xgr
22
25
  from peft import PeftModel
23
-
26
+ from pydantic import BaseModel
24
27
  from transformers import AutoTokenizer
28
+
25
29
  from mostlyai.engine._language.common import load_base_model_and_config
26
- from mostlyai.engine._language.formatron_utils import monkey_patch_formatron
30
+ from mostlyai.engine._language.engine.base import EngineMetrics, LanguageEngine
27
31
  from mostlyai.engine._language.tokenizer_utils import tokenize_fn
32
+ from mostlyai.engine._language.xgrammar_utils import create_compiled_grammars
33
+
34
+
35
+ class XGrammarLogitsProcessor(transformers.LogitsProcessor):
36
+ """
37
+ Inspired by [LogitsProcessor](https://github.com/mlc-ai/xgrammar/blob/414473e7c029d0d9e2dfbeacb48afa946d0e3419/python/xgrammar/contrib/hf.py#L14).
38
+ HuggingFace's XGrammarLogitsProcessor cannot be reused. Logits processors must be initialized for each call to generate().
39
+ """
40
+
41
+ def __init__(self, compiled_grammars: list[xgr.CompiledGrammar]):
42
+ self.compiled_grammars = compiled_grammars
43
+ self.vocab_size = self.compiled_grammars[0].tokenizer_info.vocab_size
44
+ self.batch_size = len(compiled_grammars)
45
+
46
+ self.matchers: list[xgr.GrammarMatcher] | None = None
47
+ self.token_bitmask: torch.Tensor | None = None
48
+ self.prefilled = False
49
+
50
+ def __call__(self, input_ids: torch.LongTensor, scores: torch.FloatTensor) -> torch.FloatTensor:
51
+ # lazily initialize GrammarMatchers and bitmask
52
+ if self.matchers is None:
53
+ self.matchers = [xgr.GrammarMatcher(self.compiled_grammars[i]) for i in range(self.batch_size)]
54
+ self.token_bitmask = xgr.allocate_token_bitmask(self.batch_size, self.vocab_size)
55
+
56
+ if input_ids.shape[0] != self.batch_size:
57
+ raise RuntimeError(
58
+ "Expect input_ids.shape[0] to be XGrammarLogitsProcessor.batch_size. "
59
+ + f"Got {input_ids.shape[0]} for the former, and {self.batch_size} for the latter."
60
+ )
61
+
62
+ if not self.prefilled:
63
+ # have not sampled a token yet
64
+ self.prefilled = True
65
+ else:
66
+ for i in range(self.batch_size):
67
+ if not self.matchers[i].is_terminated():
68
+ sampled_token = input_ids[i][-1]
69
+ assert self.matchers[i].accept_token(sampled_token)
28
70
 
29
- from mostlyai.engine._language.engine.base import EngineMetrics, LanguageEngine
30
- from formatron.formatter import FormatterBuilder
71
+ for i in range(self.batch_size):
72
+ if not self.matchers[i].is_terminated():
73
+ self.matchers[i].fill_next_token_bitmask(self.token_bitmask, i)
74
+
75
+ device_type = scores.device.type
76
+ if device_type != "cuda":
77
+ scores = scores.to("cpu")
78
+ xgr.apply_token_bitmask_inplace(scores, self.token_bitmask.to(scores.device))
79
+ if device_type != "cuda":
80
+ scores = scores.to(device_type)
81
+ return scores
31
82
 
32
83
 
33
84
  class HuggingFaceEngine(LanguageEngine):
@@ -37,13 +88,13 @@ class HuggingFaceEngine(LanguageEngine):
37
88
  self.device = device
38
89
  self.max_new_tokens = max_new_tokens
39
90
  self.tokenizer_max_length = tokenizer_max_length
91
+ self.is_peft_adapter = (Path(model_path) / "adapter_config.json").exists()
40
92
 
41
- is_peft_adapter = (Path(model_path) / "adapter_config.json").exists()
42
93
  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
94
+ self._model, self._model_config = load_base_model_and_config(
95
+ model_path, device=device, is_peft_adapter=self.is_peft_adapter, is_training=False
45
96
  )
46
- if is_peft_adapter:
97
+ if self.is_peft_adapter:
47
98
  self._model = PeftModel.from_pretrained(self._model, model_path, is_trainable=False)
48
99
  self._model = self._model.merge_and_unload()
49
100
  self._default_batch_size = 64
@@ -62,12 +113,10 @@ class HuggingFaceEngine(LanguageEngine):
62
113
  )
63
114
 
64
115
  # 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(
116
+ is_trained_lstm_tokenizer = not self.is_peft_adapter and self.tokenizer.vocab_size > len(
66
117
  self.tokenizer.special_tokens_map
67
118
  )
68
- self._json_enforcing_possible = is_peft_adapter or is_trained_lstm_tokenizer
69
- if self.supports_json_enforcing():
70
- monkey_patch_formatron()
119
+ self._json_enforcing_possible = self.is_peft_adapter or is_trained_lstm_tokenizer
71
120
  self._logits_processors = None
72
121
 
73
122
  def get_default_batch_size(self) -> int:
@@ -76,12 +125,14 @@ class HuggingFaceEngine(LanguageEngine):
76
125
  def supports_json_enforcing(self) -> bool:
77
126
  return self._json_enforcing_possible
78
127
 
79
- def initialize_logits_processors(
80
- self, formatter_builders: list[FormatterBuilder], vocab_processors: list[Callable] | None = None
81
- ):
82
- self._logits_processors = create_formatter_logits_processor_list(
83
- tokenizer=self.tokenizer, formatter_builders=formatter_builders, vocab_processors=vocab_processors
128
+ def initialize_logits_processors(self, schemas: Generator[BaseModel]):
129
+ compiled_grammars = create_compiled_grammars(
130
+ schemas=schemas,
131
+ tokenizer=self.tokenizer,
132
+ vocab_size=self._model_config.vocab_size,
133
+ is_peft_adapter=self.is_peft_adapter,
84
134
  )
135
+ self._logits_processors = [XGrammarLogitsProcessor(list(compiled_grammars))]
85
136
 
86
137
  def generate(
87
138
  self, text: list[str], sampling_temperature: float, sampling_top_p: float
@@ -107,22 +158,14 @@ class HuggingFaceEngine(LanguageEngine):
107
158
  temperature=sampling_temperature if do_sample else None,
108
159
  top_p=sampling_top_p if do_sample else None,
109
160
  bos_token_id=self.tokenizer.bos_token_id,
110
- pad_token_id=self.tokenizer.eos_token_id, # must be eos or will get ValueError from formatron
161
+ pad_token_id=self.tokenizer.pad_token_id,
111
162
  eos_token_id=self.tokenizer.eos_token_id,
112
163
  )
113
164
 
114
- if self._logits_processors is not None:
115
- # number of formatters must match the batch size, batch size is always reduced so this is fine
116
- actual_batch_size = len(inputs["input_ids"])
117
- self._logits_processors[0]._formatters = self._logits_processors[0]._formatters[:actual_batch_size]
118
-
119
165
  t_generate = time.time()
120
166
  outputs = self._model.generate(**inputs, **generate_kwargs, logits_processor=self._logits_processors)
121
167
  generate_time = time.time() - t_generate
122
168
 
123
- if self._logits_processors:
124
- self._logits_processors[0].reset()
125
-
126
169
  _, input_length = inputs["input_ids"].shape
127
170
  # truncate the prompt from the outputs
128
171
  outputs = outputs[:, input_length:]
@@ -12,31 +12,30 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
+ from __future__ import annotations
16
+
15
17
  import contextlib
16
- from os import PathLike
18
+ import gc
17
19
  import time
18
- import typing
20
+ from collections.abc import Generator
21
+ from os import PathLike
19
22
 
20
- from mostlyai.engine._language.common import is_bf16_supported
21
- from mostlyai.engine._language.engine.base import EngineMetrics, LanguageEngine
22
23
  import torch
23
- from formatron.config import EngineGenerationConfig
24
- from formatron.formatter import FormatterBuilder
25
-
24
+ import xgrammar as xgr
26
25
  from peft import PeftConfig
27
-
28
- from transformers import AutoTokenizer, AutoConfig, PreTrainedTokenizerBase
29
- from mostlyai.engine._language.formatron_utils import monkey_patch_formatron
26
+ from pydantic import BaseModel
27
+ from transformers import AutoConfig, AutoTokenizer
30
28
  from vllm import LLM, SamplingParams
31
- from vllm.lora.request import LoRARequest
32
29
  from vllm.config import _get_and_verify_max_len
33
- from mostlyai.engine._language.tokenizer_utils import tokenize_fn
34
- from formatron.integrations.vllm import create_engine_vocabulary, FormattersLogitsProcessor
35
- from vllm.distributed import destroy_model_parallel, destroy_distributed_environment
36
-
37
- import gc
30
+ from vllm.distributed import destroy_distributed_environment, destroy_model_parallel
31
+ from vllm.lora.request import LoRARequest
38
32
  from vllm.platforms import current_platform
39
33
 
34
+ from mostlyai.engine._language.common import is_bf16_supported
35
+ from mostlyai.engine._language.engine.base import EngineMetrics, LanguageEngine
36
+ from mostlyai.engine._language.tokenizer_utils import tokenize_fn
37
+ from mostlyai.engine._language.xgrammar_utils import create_compiled_grammars
38
+
40
39
 
41
40
  def cleanup_dist_env_and_memory():
42
41
  """Copy from current main of vllm replace by import when possible"""
@@ -49,42 +48,76 @@ def cleanup_dist_env_and_memory():
49
48
  torch.cuda.empty_cache()
50
49
 
51
50
 
52
- def create_vllm_logits_processors(
53
- llm: LLM,
54
- formatter_builders: typing.Sequence[FormatterBuilder | None],
55
- configs: typing.Sequence[EngineGenerationConfig] = None,
56
- vocab_processors: list[typing.Callable] | None = None,
57
- ) -> list[FormattersLogitsProcessor]:
51
+ class XGrammarLogitsProcessor:
58
52
  """
59
- Create list of formatter logits processors without creating engine vocabulary several times.
53
+ Inspired by [XGrammarLogitsProcessor](https://github.com/vllm-project/vllm/blob/a43aa183dc0cb2639044c15d272e0ce1941392b0/vllm/model_executor/guided_decoding/xgrammar_decoding.py#L280).
54
+ VLLM's XGrammarLogitsProcessor can be reused.
60
55
  """
61
- tokenizer = llm.get_tokenizer()
62
- vocab = create_engine_vocabulary(tokenizer, vocab_processors)
63
- formatters = [
64
- i.build(vocab, lambda tokens: tokenizer.decode(tokens)) if i is not None else None for i in formatter_builders
65
- ]
66
- return [FormattersLogitsProcessor([formatter], tokenizer.eos_token_id, configs) for formatter in formatters]
67
56
 
57
+ def __init__(self, compiled_grammar: xgr.CompiledGrammar):
58
+ self.compiled_grammar = compiled_grammar
59
+ self.tokenizer_info = compiled_grammar.tokenizer_info
60
+ self.batch_size = 1
68
61
 
69
- class MaskInvalidIndicesLogitsProcessor:
70
- """
71
- Certain models have output size greater than their vocabulary size.
72
- This logits processor masks the output indices that do not correspond
73
- to a token id.
74
- """
62
+ self.matchers: list[xgr.GrammarMatcher] | None = None
63
+ self.token_bitmask: torch.Tensor | None = None
64
+ self.prefilled = False
75
65
 
76
- def __init__(self, tokenizer: PreTrainedTokenizerBase): # : PretrainedTokenizer
77
- self.mask: torch.Tensor | None = None
78
- self.valid_token_ids = torch.tensor(list(tokenizer.vocab.values()))
66
+ def __call__(self, input_ids: tuple[int], scores: torch.Tensor) -> torch.Tensor:
67
+ # lazily initialize GrammarMatchers and bitmask
68
+ if self.matchers is None:
69
+ self.matchers = [xgr.GrammarMatcher(self.compiled_grammar) for _ in range(self.batch_size)]
70
+ self.token_bitmask = xgr.allocate_token_bitmask(self.batch_size, self.tokenizer_info.vocab_size)
79
71
 
80
- def __call__(self, input_ids: list[int], scores: torch.Tensor) -> torch.Tensor:
81
- if self.mask is None:
82
- ninf = float("-inf")
83
- self.mask = torch.full_like(scores, ninf)
84
- self.mask[..., self.valid_token_ids] = 0
85
- scores = scores + self.mask
72
+ if not self.prefilled:
73
+ # have not sampled a token yet
74
+ self.prefilled = True
75
+ else:
76
+ for i, matcher in enumerate(self.matchers):
77
+ if not matcher.is_terminated():
78
+ sampled_token = input_ids[-1]
79
+ assert self.matchers[i].accept_token(sampled_token)
80
+
81
+ for i, matcher in enumerate(self.matchers):
82
+ if not matcher.is_terminated():
83
+ matcher.fill_next_token_bitmask(self.token_bitmask, i)
84
+
85
+ # token_bitmask is a CPU tensor for use with accept_token and
86
+ # fill_next_token_bitmask so we move it to the device of scores
87
+ device_type = scores.device.type
88
+ dtype = scores.dtype
89
+ if device_type != "cuda":
90
+ # xgrammar on cpu only supports float32 scores
91
+ # see: https://github.com/mlc-ai/xgrammar/blob/c1b64920cad24f44f235778c1c00bb52d57da01a/python/xgrammar/kernels/apply_token_bitmask_inplace_cpu.py#L22
92
+ scores = scores.to("cpu").float().unsqueeze(0)
93
+
94
+ # Note: In this method, if the tensors have different dimensions
95
+ # on CPU device fails, but on GPU it runs without error. Hence the
96
+ # unsqueeze above for scores, to match the token bitmask shape
97
+ xgr.apply_token_bitmask_inplace(scores, self.token_bitmask.to(scores.device, non_blocking=True))
98
+ if device_type != "cuda":
99
+ scores = scores.to(dtype).to(device_type).squeeze()
86
100
  return scores
87
101
 
102
+ def clone(self) -> XGrammarLogitsProcessor:
103
+ """Create a new instance with shared compiled grammar but separate state"""
104
+ new_processor = XGrammarLogitsProcessor(self.compiled_grammar)
105
+
106
+ # create fresh matchers for the new sequence
107
+ if self.matchers is not None:
108
+ new_processor.matchers = [xgr.GrammarMatcher(self.compiled_grammar) for _ in range(self.batch_size)]
109
+
110
+ # create a new token bitmask with the same size
111
+ if self.token_bitmask is not None:
112
+ new_processor.token_bitmask = self.token_bitmask
113
+
114
+ new_processor.batch_size = self.batch_size
115
+
116
+ # reset prefilled state for new sequence
117
+ new_processor.prefilled = False
118
+
119
+ return new_processor
120
+
88
121
 
89
122
  class VLLMEngine(LanguageEngine):
90
123
  def __init__(
@@ -113,7 +146,6 @@ class VLLMEngine(LanguageEngine):
113
146
  swap_space=0,
114
147
  disable_log_stats=True,
115
148
  )
116
- self._base_logits_processors = [MaskInvalidIndicesLogitsProcessor(self.llm.get_tokenizer())]
117
149
  self.tokenizer = AutoTokenizer.from_pretrained(
118
150
  model_path,
119
151
  padding_side="left",
@@ -124,7 +156,6 @@ class VLLMEngine(LanguageEngine):
124
156
  add_eos_token=False,
125
157
  )
126
158
  self._logits_processors = None
127
- monkey_patch_formatron()
128
159
 
129
160
  def get_default_batch_size(self) -> int:
130
161
  return 192
@@ -132,12 +163,14 @@ class VLLMEngine(LanguageEngine):
132
163
  def supports_json_enforcing(self) -> bool:
133
164
  return True
134
165
 
135
- def initialize_logits_processors(
136
- self, formatter_builders: list[FormatterBuilder], vocab_processors: list[typing.Callable] | None = None
137
- ):
138
- self._logits_processors = create_vllm_logits_processors(
139
- llm=self.llm, formatter_builders=formatter_builders, configs=None, vocab_processors=vocab_processors
166
+ def initialize_logits_processors(self, schemas: Generator[BaseModel]):
167
+ compiled_grammars = create_compiled_grammars(
168
+ schemas=schemas,
169
+ tokenizer=self.llm.get_tokenizer(),
170
+ vocab_size=self.llm.llm_engine.get_model_config().get_vocab_size(),
171
+ is_peft_adapter=True,
140
172
  )
173
+ self._logits_processors = [XGrammarLogitsProcessor(compiled_grammar) for compiled_grammar in compiled_grammars]
141
174
 
142
175
  def generate(
143
176
  self, text: list[str], sampling_temperature: float, sampling_top_p: float
@@ -156,23 +189,15 @@ class VLLMEngine(LanguageEngine):
156
189
  tokenize_time = time.time() - t_tokenize
157
190
 
158
191
  actual_batch_size = len(inputs["input_ids"])
159
- if self._logits_processors is not None:
160
- sampling_params = [
161
- SamplingParams(
162
- max_tokens=self.max_new_tokens,
163
- temperature=sampling_temperature,
164
- top_p=sampling_top_p,
165
- logits_processors=[lp, *self._base_logits_processors],
166
- )
167
- for lp in self._logits_processors[:actual_batch_size]
168
- ]
169
- else:
170
- sampling_params = SamplingParams(
192
+ sampling_params = [
193
+ SamplingParams(
171
194
  max_tokens=self.max_new_tokens,
172
195
  temperature=sampling_temperature,
173
196
  top_p=sampling_top_p,
174
- logits_processors=self._base_logits_processors,
197
+ logits_processors=[lp],
175
198
  )
199
+ for lp in self._logits_processors[:actual_batch_size]
200
+ ]
176
201
  t_generate = time.time()
177
202
  outputs = self.llm.generate(
178
203
  prompts=None,
@@ -182,9 +207,6 @@ class VLLMEngine(LanguageEngine):
182
207
  lora_request=self._lora_request,
183
208
  )
184
209
  generate_time = time.time() - t_generate
185
- if self._logits_processors is not None:
186
- for lp in self._logits_processors:
187
- lp.reset()
188
210
  metrics = EngineMetrics(tokenize_time=tokenize_time, generate_time=generate_time)
189
211
  return [r.outputs[0].token_ids for r in outputs], metrics
190
212
 
@@ -40,12 +40,9 @@ from mostlyai.engine._encoding_types.language.numeric import decode_language_num
40
40
  from mostlyai.engine._encoding_types.language.text import decode_text
41
41
  from mostlyai.engine._language.common import MAX_LENGTH
42
42
  from mostlyai.engine._language.encoding import encode_df
43
+ from mostlyai.engine._language.xgrammar_utils import create_schemas
43
44
  from mostlyai.engine._workspace import ensure_workspace_dir, Workspace, reset_dir
44
- from mostlyai.engine._language.formatron_utils import (
45
- get_formatter_builders,
46
- prepare_seed_for_formatron,
47
- get_vocab_processors,
48
- )
45
+ from mostlyai.engine._language.xgrammar_utils import ensure_seed_can_be_tokenized
49
46
  from mostlyai.engine.domain import ModelEncodingType, RareCategoryReplacementMethod
50
47
 
51
48
  INVALID_VALUE = "_INVALID_" # when JSON parsing fails, the values of target columns will be set to this
@@ -274,8 +271,7 @@ def generate(
274
271
  batch_size = sample_size
275
272
  _LOG.info(f"{batch_size=}")
276
273
 
277
- # prepare seed data for clean consumption by formatron
278
- seed_data = prepare_seed_for_formatron(seed_data, engine.tokenizer)
274
+ seed_data = ensure_seed_can_be_tokenized(seed_data, engine.tokenizer)
279
275
  seeded_tgt_columns = seed_data.columns.to_list()
280
276
 
281
277
  total_tokenize_fn_time = 0
@@ -284,14 +280,16 @@ def generate(
284
280
 
285
281
  enforce_json_output = engine.supports_json_enforcing()
286
282
  _LOG.info(f"{enforce_json_output=}")
287
- formatron_vocab_processors = get_vocab_processors(is_peft_adapter)
288
283
 
289
- if enforce_json_output and len(seeded_tgt_columns) == 0:
284
+ initialize_logits_processors_once = len(seeded_tgt_columns) == 0 and engine.__class__.__name__ == "VLLMEngine"
285
+ if enforce_json_output and initialize_logits_processors_once:
290
286
  t0 = time.time()
291
- formatter_builders = get_formatter_builders(
292
- size=batch_size, stats=tgt_stats, rare_category_replacement_method=rare_category_replacement_method
287
+ schemas = create_schemas(
288
+ size=batch_size,
289
+ stats=tgt_stats,
290
+ rare_category_replacement_method=rare_category_replacement_method,
293
291
  )
294
- engine.initialize_logits_processors(formatter_builders, formatron_vocab_processors)
292
+ engine.initialize_logits_processors(schemas=schemas)
295
293
  total_logits_processor_build_time += time.time() - t0
296
294
 
297
295
  # keep at most 500k samples in memory before decoding and writing to disk
@@ -305,15 +303,14 @@ def generate(
305
303
  ctx_batch = ctx_data.iloc[samples_processed : samples_processed + batch_size]
306
304
  ctx_keys = ctx_batch[ctx_primary_key]
307
305
 
308
- if enforce_json_output and len(seeded_tgt_columns) > 0:
306
+ if enforce_json_output and not initialize_logits_processors_once:
309
307
  t0 = time.time()
310
- # some columns are seeded, so we need to create a new logits processor for each batch
311
- formatter_builders = get_formatter_builders(
308
+ schemas = create_schemas(
312
309
  seed_df=sample_seed_batch,
313
310
  stats=tgt_stats,
314
311
  rare_category_replacement_method=rare_category_replacement_method,
315
312
  )
316
- engine.initialize_logits_processors(formatter_builders, formatron_vocab_processors)
313
+ engine.initialize_logits_processors(schemas=schemas)
317
314
  total_logits_processor_build_time += time.time() - t0
318
315
 
319
316
  outputs, metrics = engine.generate(
@@ -0,0 +1,147 @@
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 __future__ import annotations
16
+
17
+ import json
18
+ from collections.abc import Generator
19
+ from typing import Literal
20
+
21
+ import pandas as pd
22
+ import xgrammar as xgr
23
+ from pydantic import BaseModel, Field, SkipValidation, create_model
24
+ from transformers import PreTrainedTokenizerBase
25
+ from xgrammar.testing import _json_schema_to_ebnf
26
+
27
+ from mostlyai.engine._common import STRING
28
+ from mostlyai.engine._encoding_types.language.categorical import CATEGORICAL_UNKNOWN_TOKEN
29
+ from mostlyai.engine.domain import ModelEncodingType, RareCategoryReplacementMethod
30
+
31
+ JSON_NULL = "null"
32
+
33
+
34
+ def prepend_grammar_root_with_space(grammar: str) -> str:
35
+ # XGrammar always starts with "{" when enforcing JSON Schema
36
+ # training is done on strings like ` {...} {...}`
37
+ # later, generation is done with prompt like ` {...}`
38
+ # so the first output token must be a space
39
+ start_of_grammar = 'root ::= "{"'
40
+ start_of_grammar_with_space = 'root ::= " {"'
41
+ assert start_of_grammar in grammar
42
+ return grammar.replace(start_of_grammar, start_of_grammar_with_space)
43
+
44
+
45
+ def ensure_seed_can_be_tokenized(sample_seed: pd.DataFrame, tokenizer: PreTrainedTokenizerBase) -> pd.DataFrame:
46
+ def transform(x: str | pd._libs.missing.NAType) -> str:
47
+ if pd.isna(x):
48
+ null = tokenizer.decode(tokenizer.encode(JSON_NULL), skip_special_tokens=True)
49
+ # xgrammar needs to be able to express JSON_NULL with available vocabulary
50
+ # if that's the case, harmonize null-like values to None (e.g. pd.NA would cause xgrammar to fail)
51
+ # otherwise, fallback to empty string
52
+ return None if null == JSON_NULL else ""
53
+ # skip tokens unseen during training
54
+ return tokenizer.decode(tokenizer.encode(x), skip_special_tokens=True)
55
+
56
+ return sample_seed.astype(STRING).map(transform)
57
+
58
+
59
+ def create_schemas(
60
+ *,
61
+ seed_df: pd.DataFrame | None = None,
62
+ size: int | None = None,
63
+ stats: dict,
64
+ rare_category_replacement_method: RareCategoryReplacementMethod,
65
+ ) -> Generator[BaseModel]:
66
+ assert (seed_df is not None) ^ (size is not None), "exactly one of seed_df or size must be provided"
67
+ if seed_df is None:
68
+ seed_df = pd.DataFrame(index=range(size))
69
+ unseeded_fields = [c for c in list(stats["columns"].keys()) if c not in seed_df.columns.to_list()]
70
+ field_types = {
71
+ t: [col for col, col_stats in stats["columns"].items() if col_stats["encoding_type"] == t]
72
+ for t in ModelEncodingType
73
+ }
74
+ categorical_fields = field_types.get(ModelEncodingType.language_categorical, [])
75
+ numeric_fields = field_types.get(ModelEncodingType.language_numeric, [])
76
+ datetime_fields = field_types.get(ModelEncodingType.language_datetime, [])
77
+ cache = {}
78
+ for _, seed_row in seed_df.iterrows():
79
+ cache_key = hash(tuple(sorted([(field_name, str(seed_value)) for field_name, seed_value in seed_row.items()])))
80
+ if cache_key in cache:
81
+ yield cache[cache_key]
82
+ continue
83
+ model_dict = {}
84
+ if not seed_row.empty:
85
+ model_dict |= {field_name: (Literal[seed_value], ...) for field_name, seed_value in seed_row.items()} # type: ignore[valid-type]
86
+ for field_name in unseeded_fields:
87
+ if field_name in categorical_fields:
88
+ categories = stats["columns"][field_name]["categories"]
89
+ if rare_category_replacement_method == RareCategoryReplacementMethod.sample and len(categories) > 1:
90
+ categories = [c for c in categories if c != CATEGORICAL_UNKNOWN_TOKEN]
91
+ model_dict[field_name] = (Literal[tuple(categories)], ...) # type: ignore[valid-type]
92
+ elif field_name in numeric_fields:
93
+ max_scale = stats["columns"][field_name]["max_scale"]
94
+ min_min5 = min(stats["columns"][field_name]["min5"])
95
+ max_max5 = max(stats["columns"][field_name]["max5"])
96
+ if max_scale == 0:
97
+ model_dict[field_name] = (SkipValidation[int], Field(ge=min_min5, le=max_max5))
98
+ else:
99
+ model_dict[field_name] = (
100
+ SkipValidation[float],
101
+ Field(ge=min_min5, le=max_max5, decimal_places=max_scale),
102
+ )
103
+ elif field_name in datetime_fields:
104
+ model_dict[field_name] = (
105
+ SkipValidation[str],
106
+ Field(
107
+ pattern=r"""(19\d{2}|20\d{2})-(0[1-9]|1[0-2])-(0[1-9]|1[0-9]|2[0-9]|3[0-1])T([0-1][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])"""
108
+ ),
109
+ )
110
+ else:
111
+ model_dict[field_name] = (str, ...)
112
+ schema = create_model("TargetModel", **model_dict)
113
+ cache[cache_key] = schema
114
+ yield schema
115
+
116
+
117
+ def _get_tokenizer_info_for_lstm(tokenizer: PreTrainedTokenizerBase, vocab_size: int):
118
+ # trimmed down version of xgr.TokenizerInfo.from_huggingface
119
+ # the original function sets vocab_type to VocabType.RAW,
120
+ # but LSTM tokenizer needs VocabType.BYTE_FALLBACK, because of the usage of metaspace ("▁")
121
+ encoded_vocab = [""] * vocab_size
122
+ for token, idx in tokenizer.get_vocab().items():
123
+ if idx < vocab_size:
124
+ encoded_vocab[idx] = token
125
+ tokenizer_info = xgr.TokenizerInfo(
126
+ encoded_vocab,
127
+ vocab_type=xgr.VocabType.BYTE_FALLBACK,
128
+ vocab_size=vocab_size,
129
+ stop_token_ids=[tokenizer.eos_token_id],
130
+ add_prefix_space=True,
131
+ )
132
+ return tokenizer_info
133
+
134
+
135
+ def create_compiled_grammars(
136
+ schemas: Generator[BaseModel], tokenizer: PreTrainedTokenizerBase, vocab_size: int, is_peft_adapter: bool
137
+ ) -> Generator[xgr.CompiledGrammar]:
138
+ # in general, there might be misalignment between the model's and tokenizer's vocab_size
139
+ # the former is expected by XGrammar
140
+ make_tokenizer_info = xgr.TokenizerInfo.from_huggingface if is_peft_adapter else _get_tokenizer_info_for_lstm
141
+ tokenizer_info = make_tokenizer_info(tokenizer, vocab_size=vocab_size)
142
+ grammar_compiler = xgr.GrammarCompiler(tokenizer_info)
143
+ schemas = (json.dumps(schema.model_json_schema()) for schema in schemas)
144
+ grammars = (_json_schema_to_ebnf(schema) for schema in schemas)
145
+ grammars = (prepend_grammar_root_with_space(grammar) for grammar in grammars)
146
+ compiled_grammars = (grammar_compiler.compile_grammar(grammar) for grammar in grammars)
147
+ return compiled_grammars
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mostlyai-engine"
3
- version = "1.1.11"
3
+ version = "1.2.0"
4
4
  description = "Synthetic Data Engine"
5
5
  authors = [{ name = "MOSTLY AI", email = "dev@mostly.ai" }]
6
6
  requires-python = ">=3.10"
@@ -26,41 +26,40 @@ classifiers = [
26
26
 
27
27
  dependencies = [
28
28
  "setuptools>=75.0.0,<76",
29
- "numpy>=1.26.3,<2",
29
+ "numpy>=1.26.3",
30
30
  "pandas~=2.2.0",
31
31
  "pyarrow>=16.0.0",
32
- "joblib>=1.3.0,<2",
32
+ "joblib>=1.4.2",
33
33
  "psutil>=5.9.5,<6", # upgrade when colab psutil is updated
34
- "tokenizers>=0.21.0,<0.22",
35
- "transformers>=4.45.2,<5",
36
- "datasets>=3.0.0,<4",
37
- "accelerate>=0.31.0",
34
+ "tokenizers>=0.21.0",
35
+ "transformers>=4.51.0",
36
+ "datasets>=3.0.0",
37
+ "accelerate>=1.5.0",
38
38
  "peft>=0.12.0",
39
- "huggingface-hub>=0.28.0,<0.29",
40
- "opacus>=1.5.2,<2",
41
- "formatron>=0.4.11,<0.5",
39
+ "huggingface-hub>=0.30.0",
40
+ "opacus>=1.5.2", # switch to 1.5.4 (once released) to allow numpy 2
41
+ "xgrammar==0.1.17", # for vllm 0.8.2 compatibility
42
42
  "json-repair>=0.30.0",
43
- "rich>=13.9.4,<14",
44
43
  "torch>=2.6.0; sys_platform == 'darwin'",
45
- "torch==2.6.0; sys_platform == 'linux'", # for vllm 0.8.1 compatibility
46
- "torchaudio==2.6.0; sys_platform == 'linux'", # for vllm 0.8.1 compatibility
47
- "torchvision==0.21.0; sys_platform == 'linux'" # for vllm 0.8.1 compatibility
44
+ "torch==2.6.0; sys_platform == 'linux'", # for vllm 0.8.2 compatibility
45
+ "torchaudio==2.6.0; sys_platform == 'linux'", # for vllm 0.8.2 compatibility
46
+ "torchvision==0.21.0; sys_platform == 'linux'" # for vllm 0.8.2 compatibility
48
47
  ]
49
48
 
50
49
  [project.optional-dependencies]
51
50
  gpu = [
52
- "torch==2.6.0", # for vllm 0.8.1 compatibility
53
- "torchaudio==2.6.0", # for vllm 0.8.1 compatibility
54
- "torchvision==0.21.0", # for vllm 0.8.1 compatibility
51
+ "torch==2.6.0", # for vllm 0.8.2 compatibility
52
+ "torchaudio==2.6.0", # for vllm 0.8.2 compatibility
53
+ "torchvision==0.21.0", # for vllm 0.8.2 compatibility
55
54
  "bitsandbytes>=0.42.0,<0.43; sys_platform == 'darwin'",
56
55
  "vllm>=0.7.3,<0.9; sys_platform == 'darwin'",
57
56
  "bitsandbytes>=0.43.3,<0.46; sys_platform == 'linux'",
58
- "vllm>=0.8.1,<0.9; sys_platform == 'linux'",
57
+ "vllm>=0.8.2,<0.9; sys_platform == 'linux'",
59
58
  ]
60
59
  cpu = [
61
- "torch==2.6.0+cpu; sys_platform == 'linux'", # for vllm 0.8.0 compatibility
62
- "torchaudio==2.6.0+cpu; sys_platform == 'linux'", # for vllm 0.8.0 compatibility
63
- "torchvision==0.21.0+cpu; sys_platform == 'linux'", # for vllm 0.8.0 compatibility
60
+ "torch==2.6.0+cpu; sys_platform == 'linux'", # for vllm 0.8.2 compatibility
61
+ "torchaudio==2.6.0+cpu; sys_platform == 'linux'", # for vllm 0.8.2 compatibility
62
+ "torchvision==0.21.0+cpu; sys_platform == 'linux'", # for vllm 0.8.2 compatibility
64
63
  "torch==2.6.0; sys_platform != 'linux'",
65
64
  ]
66
65
 
@@ -118,8 +117,11 @@ include = ["mostlyai/engine"]
118
117
  [tool.hatch.build.targets.wheel]
119
118
  include = ["mostlyai/engine"]
120
119
 
120
+ [tool.hatch.metadata]
121
+ allow-direct-references = true
122
+
121
123
  [build-system]
122
- requires = ["hatchling"]
124
+ requires = ["hatchling", "hatch-vcs"]
123
125
  build-backend = "hatchling.build"
124
126
 
125
127
  [tool.ruff]
@@ -1,264 +0,0 @@
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
- import typing
17
-
18
-
19
- import pandas as pd
20
- from formatron.schemas.pydantic import ClassSchema
21
- from json import JSONDecodeError
22
- from pydantic import Field, SkipValidation, ValidationError
23
- from formatron.formatter import FormatterBuilder
24
- from formatron import schemas
25
- from formatron.formats import json
26
- from typing import Literal
27
- from pydantic import create_model
28
- from transformers import PreTrainedTokenizerBase
29
- from mostlyai.engine._encoding_types.language.categorical import CATEGORICAL_UNKNOWN_TOKEN
30
- from mostlyai.engine.domain import ModelEncodingType, RareCategoryReplacementMethod
31
-
32
- JSON_NULL = "null"
33
-
34
-
35
- def prepare_seed_for_formatron(sample_seed: pd.DataFrame, tokenizer: PreTrainedTokenizerBase) -> pd.DataFrame:
36
- def transform(x: str | None) -> str:
37
- if pd.isna(x):
38
- null = tokenizer.decode(tokenizer.encode(JSON_NULL), skip_special_tokens=True)
39
- # formatron needs to be able to express JSON_NULL with available vocabulary
40
- # if that's the case, harmonize null-like values to None (e.g. pd.NA would cause formatron to fail)
41
- # otherwise, fallback to empty string
42
- return None if null == JSON_NULL else ""
43
- # skip tokens unseen during training
44
- return tokenizer.decode(tokenizer.encode(x), skip_special_tokens=True)
45
-
46
- return sample_seed.astype("string[pyarrow]").map(transform)
47
-
48
-
49
- def get_formatter_builders(
50
- *,
51
- seed_df: pd.DataFrame | None = None,
52
- size: int | None = None,
53
- stats: dict,
54
- rare_category_replacement_method: RareCategoryReplacementMethod,
55
- ) -> list[FormatterBuilder]:
56
- assert (seed_df is not None) ^ (size is not None), "exactly one of seed_df or size must be provided"
57
- formatter_builders = []
58
- if seed_df is None:
59
- seed_df = pd.DataFrame(index=range(size))
60
- unseeded_fields = [c for c in list(stats["columns"].keys()) if c not in seed_df.columns.to_list()]
61
- field_types = {
62
- t: [col for col, col_stats in stats["columns"].items() if col_stats["encoding_type"] == t]
63
- for t in ModelEncodingType
64
- }
65
- categorical_fields = field_types.get(ModelEncodingType.language_categorical, [])
66
- numeric_fields = field_types.get(ModelEncodingType.language_numeric, [])
67
- datetime_fields = field_types.get(ModelEncodingType.language_datetime, [])
68
- cache = {}
69
- for _, seed_row in seed_df.iterrows():
70
- cache_key = hash(tuple(sorted([(field_name, str(seed_value)) for field_name, seed_value in seed_row.items()])))
71
- if cache_key in cache:
72
- formatter_builders.append(cache[cache_key])
73
- continue
74
- model_dict = {}
75
- if not seed_row.empty:
76
- model_dict |= {field_name: (Literal[seed_value], ...) for field_name, seed_value in seed_row.items()} # type: ignore[valid-type]
77
- for field_name in unseeded_fields:
78
- if field_name in categorical_fields:
79
- categories = stats["columns"][field_name]["categories"]
80
- if rare_category_replacement_method == RareCategoryReplacementMethod.sample and len(categories) > 1:
81
- categories = [c for c in categories if c != CATEGORICAL_UNKNOWN_TOKEN]
82
- model_dict[field_name] = (Literal[tuple(categories)], ...) # type: ignore[valid-type]
83
- elif field_name in numeric_fields:
84
- max_scale = stats["columns"][field_name]["max_scale"]
85
- min_min5 = min(stats["columns"][field_name]["min5"])
86
- max_max5 = max(stats["columns"][field_name]["max5"])
87
- if max_scale == 0:
88
- model_dict[field_name] = (SkipValidation[int], Field(ge=min_min5, le=max_max5))
89
- else:
90
- model_dict[field_name] = (
91
- SkipValidation[float],
92
- Field(ge=min_min5, le=max_max5, decimal_places=max_scale),
93
- )
94
- elif field_name in datetime_fields:
95
- model_dict[field_name] = (
96
- SkipValidation[str],
97
- Field(
98
- pattern=r"""(19\\d{2}|20\\d{2})-(0[1-9]|1[0-2])-(0[1-9]|1[0-9]|2[0-9]|3[0-1])T([0-1][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])"""
99
- ),
100
- )
101
- else:
102
- model_dict[field_name] = (str, ...)
103
- schema = create_model("TargetModel", **model_dict, __base__=MostlyClassSchema)
104
- formatter_builder = FormatterBuilder()
105
- formatter_builder.append_str(f"{formatter_builder.json(schema, capture_name=None)}")
106
- cache[cache_key] = formatter_builder
107
- formatter_builders.append(formatter_builder)
108
- return formatter_builders
109
-
110
-
111
- def get_vocab_processors(is_peft_adapter: bool) -> list[typing.Callable] | None:
112
- if not is_peft_adapter:
113
-
114
- def update_vocab_lstm(token_to_char: dict[bytes, bytes]):
115
- """
116
- Maps special tokens ("▁", "␊") back to their original representation (" ", "\n")
117
- (used in LSTM tokenizer)
118
- """
119
- token_to_char["\u2581".encode()] = b" " # "▁" -> " "
120
- token_to_char["\u240a".encode()] = b"\n" # "␊" -> "\n"
121
-
122
- return [update_vocab_lstm]
123
- return None
124
-
125
-
126
- class MostlyClassSchema(ClassSchema):
127
- @classmethod
128
- def from_json(cls, _json: str) -> "MostlyClassSchema":
129
- """
130
- Create a MostlyClassSchema from a JSON string.
131
- """
132
- try:
133
- return cls.model_validate_json(_json)
134
- except ValidationError as e:
135
- for error in e.errors():
136
- if error["type"] == "json_invalid":
137
- raise JSONDecodeError(
138
- f"Caught pydantic ValidationError {e}, reraising as JSONDecodeError", _json, 0
139
- )
140
- raise e
141
-
142
-
143
- # copy formatron: direct copy from formatron
144
- def _string_metadata(current: type, nonterminal: str):
145
- min_length = current.metadata.get("min_length")
146
- max_length = current.metadata.get("max_length")
147
- pattern = current.metadata.get("pattern")
148
- substring_of = current.metadata.get("substring_of")
149
- if pattern:
150
- assert not (min_length or max_length or substring_of), (
151
- "pattern is mutually exclusive with min_length, max_length and substring_of"
152
- )
153
- if substring_of:
154
- assert not (min_length or max_length or pattern), (
155
- "substring_of is mutually exclusive with min_length, max_length and pattern"
156
- )
157
- repetition_map = {
158
- (True, False): f"{{{min_length},}}",
159
- (False, True): f"{{0,{max_length}}}",
160
- (True, True): f"{{{min_length},{max_length}}}",
161
- }
162
- repetition = repetition_map.get((min_length is not None, max_length is not None))
163
- if repetition is not None:
164
- return (
165
- rf"""{nonterminal} ::= #'"([^\\\\"\u0000-\u001f]|\\\\["\\\\bfnrt/]|\\\\u[0-9A-Fa-f]{{4}}){repetition}"';
166
- """,
167
- [],
168
- )
169
- if pattern is not None:
170
- pattern = pattern.replace("'", "\\'")
171
- return f"""{nonterminal} ::= #'"{pattern}"';\n""", []
172
- if substring_of is not None:
173
- return f"""{nonterminal} ::= '"' #substrs{repr(substring_of)} '"';\n""", []
174
-
175
-
176
- # completely altered vs formatron
177
- def _number_metadata(current: type, nonterminal: str):
178
- # For now only constrains number of digits and whether it is negative
179
- gt = current.metadata.get("gt")
180
- ge = current.metadata.get("ge")
181
- lt = current.metadata.get("lt")
182
- le = current.metadata.get("le")
183
- if lt is not None or gt is not None:
184
- raise NotImplementedError("gt and lt are not supported for number metadata")
185
- if le < ge:
186
- raise ValueError("le must be greater than or equal to ge")
187
-
188
- pattern_parts = []
189
- if issubclass(current.type, float):
190
- le, le_frac = str(le).split(".")
191
- ge, ge_frac = str(ge).split(".")
192
- le, le_frac = int(le), int(le_frac)
193
- ge, ge_frac = int(ge), int(ge_frac)
194
- decimal_places = current.metadata.get("decimal_places")
195
-
196
- if ge is not None and le is not None:
197
- if ge < 0 and le < 0:
198
- pattern_parts.append("-")
199
- min_num = abs(le)
200
- max_num = abs(ge)
201
- max_digits = len(str(max_num))
202
- min_digits = len(str(min_num))
203
- pattern_parts.append(rf"([1-9][0-9]{{{min_digits - 1},{max_digits - 1}}})")
204
- elif ge > 0:
205
- min_num = ge
206
- max_num = le
207
- max_digits = len(str(max_num))
208
- min_digits = len(str(min_num))
209
- pattern_parts.append(rf"([1-9][0-9]{{{min_digits - 1},{max_digits - 1}}})")
210
- else:
211
- if ge < 0:
212
- pattern_parts.append("-?")
213
- max_digits = max(len(str(abs(ge))), len(str(abs(le))))
214
- pattern_parts.append(rf"(0|[1-9][0-9]{{0,{max_digits - 1}}})")
215
-
216
- if issubclass(current.type, float):
217
- pattern_parts.append(rf"(\\.[0-9]{{0,{decimal_places}}})?")
218
-
219
- pattern = "".join(pattern_parts)
220
- return f"""{nonterminal} ::= #"{pattern}";\n""", []
221
-
222
-
223
- # copy formatron: removed sequence metadata since unnecessary and altered number_metadata to use ours
224
- def _metadata(current: type, nonterminal: str):
225
- if isinstance(current, schemas.schema.TypeWithMetadata):
226
- original = typing.get_origin(current.type)
227
- if original is None:
228
- original = current.type
229
- if not current.metadata:
230
- return "", [(current.type, nonterminal)]
231
- if isinstance(current.type, type) and issubclass(current.type, str):
232
- return _string_metadata(current, nonterminal)
233
- elif isinstance(current.type, type) and issubclass(current.type, (int, float)):
234
- return _number_metadata(current, nonterminal)
235
- return None
236
-
237
-
238
- def monkey_patch_formatron():
239
- FORMATRON_WHITESPACE_MAX_REPETITIONS = 10
240
- SPACE_NONTERMINAL = f"[ \t\n\r]{{0,{FORMATRON_WHITESPACE_MAX_REPETITIONS}}}"
241
-
242
- # Copy from formatron, altered to have limited whitespace repetitions and datetime format
243
- json.GRAMMAR_HEADER = rf"""integer ::= #"-?(0|[1-9]\\d*)";
244
- number ::= #"-?(0|[1-9]\\d*)(\\.\\d+)?([eE][+-]?\\d+)?";
245
- string ::= #'"([^\\\\"\u0000-\u001f]|\\\\["\\\\bfnrt/]|\\\\u[0-9A-Fa-f]{{4}})*"';
246
- boolean ::= "true"|"false";
247
- null ::= "null";
248
- array ::= array_begin (json_value (comma json_value)*)? array_end;
249
- object ::= object_begin (string colon json_value (comma string colon json_value)*)? object_end;
250
- json_value ::= number|string|boolean|null|array|object;
251
- comma ::= #"{SPACE_NONTERMINAL},{SPACE_NONTERMINAL}";
252
- colon ::= #"{SPACE_NONTERMINAL}:{SPACE_NONTERMINAL}";
253
- object_begin ::= #" \\{{{SPACE_NONTERMINAL}";
254
- object_end ::= #"{SPACE_NONTERMINAL}\\}}";
255
- array_begin ::= #"\\[{SPACE_NONTERMINAL}";
256
- array_end ::= #"{SPACE_NONTERMINAL}\\]";
257
- """
258
-
259
- def alter_type_to_nonterminals_metadata_inplace(type_to_nonterminals: list[typing.Callable]):
260
- metadata_idx = [idx for idx, fn in enumerate(type_to_nonterminals) if fn.__name__ == "metadata"]
261
- if len(metadata_idx) == 1:
262
- type_to_nonterminals[metadata_idx[0]] = _metadata
263
-
264
- alter_type_to_nonterminals_metadata_inplace(json._type_to_nonterminals)