mostlyai-engine 1.3.3__tar.gz → 1.4.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 (50) hide show
  1. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/PKG-INFO +1 -1
  2. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/__init__.py +1 -1
  3. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_common.py +306 -2
  4. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/language/categorical.py +15 -28
  5. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/language/datetime.py +60 -48
  6. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/language/numeric.py +56 -49
  7. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/language/text.py +5 -1
  8. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/tabular/categorical.py +20 -11
  9. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/tabular/character.py +14 -8
  10. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/tabular/datetime.py +63 -45
  11. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/tabular/itt.py +53 -28
  12. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/tabular/lat_long.py +23 -12
  13. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/tabular/numeric.py +79 -64
  14. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/encoding.py +13 -2
  15. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/generation.py +3 -0
  16. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/training.py +26 -17
  17. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/xgrammar_utils.py +4 -4
  18. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_tabular/encoding.py +4 -0
  19. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_tabular/generation.py +9 -2
  20. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_tabular/training.py +25 -16
  21. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/analysis.py +110 -81
  22. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/domain.py +18 -9
  23. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/encoding.py +9 -3
  24. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/generation.py +4 -0
  25. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/splitting.py +10 -4
  26. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/training.py +5 -1
  27. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/pyproject.toml +1 -1
  28. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/.gitignore +0 -0
  29. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/LICENSE +0 -0
  30. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/README.md +0 -0
  31. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_dtypes.py +0 -0
  32. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/__init__.py +0 -0
  33. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/language/__init__.py +0 -0
  34. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/tabular/__init__.py +0 -0
  35. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/__init__.py +0 -0
  36. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/common.py +0 -0
  37. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/engine/__init__.py +0 -0
  38. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/engine/base.py +0 -0
  39. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/engine/hf_engine.py +0 -0
  40. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/engine/vllm_engine.py +0 -0
  41. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/lstm.py +0 -0
  42. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/tokenizer_utils.py +0 -0
  43. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_memory.py +0 -0
  44. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_tabular/__init__.py +0 -0
  45. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_tabular/argn.py +0 -0
  46. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_tabular/common.py +0 -0
  47. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_tabular/fairness.py +0 -0
  48. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_training_utils.py +0 -0
  49. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_workspace.py +0 -0
  50. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/logging.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mostlyai-engine
3
- Version: 1.3.3
3
+ Version: 1.4.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
@@ -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.3.3"
25
+ __version__ = "1.4.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.*")
@@ -16,7 +16,9 @@ import inspect
16
16
  import itertools
17
17
  import json
18
18
  import logging
19
+ import os
19
20
  import platform
21
+ import struct
20
22
  import time
21
23
  from functools import wraps
22
24
  from pathlib import Path
@@ -57,6 +59,12 @@ SIDX_SUB_COLUMN_PREFIX = f"{SLEN_SIDX_SDEC_COLUMN}{PREFIX_SUB_COLUMN}sidx_" # s
57
59
  SDEC_SUB_COLUMN_PREFIX = f"{SLEN_SIDX_SDEC_COLUMN}{PREFIX_SUB_COLUMN}sdec_" # sequence index decile
58
60
  TABLE_COLUMN_INFIX = "::" # this should be consistent as in mostly-data and mostlyai-qa
59
61
 
62
+ ANALYZE_MIN_MAX_TOP_N = 1000 # the number of min/max values to be kept from each partition
63
+
64
+ # the minimal number of min/max values to trigger the reduction; if less, the min/max will be reduced to None
65
+ # this should be at least greater than the non-DP stochastic threshold for rare value protection (5 + noise)
66
+ ANALYZE_REDUCE_MIN_MAX_N = 20
67
+
60
68
  TEMPORARY_PRIMARY_KEY = "__primary_key"
61
69
 
62
70
  STRING = "string[pyarrow]" # This utilizes pyarrow's large string type since pandas 2.2
@@ -431,14 +439,12 @@ def get_sequence_length_stats(stats: dict) -> dict:
431
439
  "min": stats["seq_len"]["min"],
432
440
  "median": stats["seq_len"]["median"],
433
441
  "max": stats["seq_len"]["max"],
434
- "deciles": stats["seq_len"]["deciles"],
435
442
  }
436
443
  else:
437
444
  stats = {
438
445
  "min": 1,
439
446
  "median": 1,
440
447
  "max": 1,
441
- "deciles": [1 for i in range(11)],
442
448
  }
443
449
  return stats
444
450
 
@@ -610,3 +616,301 @@ class FixedSizeSampleBuffer:
610
616
  self.buffer = []
611
617
  self.current_size = 0
612
618
  self.n_clears += 1
619
+
620
+
621
+ def _get_log_histogram_edges(idx: int, bins: int = 64) -> tuple[float, float]:
622
+ """
623
+ Modified from OpenDP's SmartNoise SDK (MIT License)
624
+ Source: https://github.com/opendp/smartnoise-sdk/blob/main/sql/snsql/sql/_mechanisms/approx_bounds.py
625
+ """
626
+ if idx == bins:
627
+ return (0.0, 1.0)
628
+ elif idx > bins:
629
+ return (2.0 ** (idx - bins - 1), 2.0 ** (idx - bins))
630
+ elif idx == bins - 1:
631
+ return (-1.0, -0.0)
632
+ else:
633
+ return (-1 * 2.0 ** np.abs(bins - idx - 1), -1 * 2.0 ** np.abs(bins - idx - 2))
634
+
635
+
636
+ def compute_log_histogram(values: np.ndarray, bins: int = 64) -> list[int]:
637
+ """
638
+ Modified from OpenDP's SmartNoise SDK (MIT License)
639
+ Source: https://github.com/opendp/smartnoise-sdk/blob/main/sql/snsql/sql/_mechanisms/approx_bounds.py
640
+ """
641
+ hist = [0.0] * bins * 2
642
+
643
+ values = np.array(values, dtype=np.float64)
644
+ values = values[values != np.inf]
645
+ values = values[values != -np.inf]
646
+ values = values[~np.isnan(values)]
647
+ edge_list = [_get_log_histogram_edges(idx) for idx in range(len(hist))]
648
+ min_val = min([lower for lower, _ in edge_list])
649
+ max_val = max([upper for _, upper in edge_list]) - 1
650
+ values = np.clip(values, min_val, max_val)
651
+
652
+ # compute histograms
653
+ for v in values:
654
+ bin = None
655
+ for idx, (lower, upper) in enumerate(edge_list):
656
+ if lower <= v < upper:
657
+ bin = idx
658
+ break
659
+ if bin is None:
660
+ bin = idx
661
+ hist[bin] += 1
662
+
663
+ # for testing
664
+ lower, upper = _get_log_histogram_edges(bin)
665
+ return hist
666
+
667
+
668
+ def dp_approx_bounds(hist: list[int], epsilon: float) -> tuple[float | None, float | None]:
669
+ """
670
+ Modified from OpenDP's SmartNoise SDK (MIT License)
671
+ Source: https://github.com/opendp/smartnoise-sdk/blob/main/sql/snsql/sql/_mechanisms/approx_bounds.py
672
+
673
+ Estimate the minimium and maximum values of a list of values.
674
+ from: https://desfontain.es/thesis/Usability.html#usability-u-ding-
675
+
676
+ Args:
677
+ hist (list[int]): A list of log histogram counts.
678
+ epsilon (float): The privacy budget to spend estimating the bounds.
679
+
680
+ Returns:
681
+ tuple[float | None, float | None]: A tuple of the estimated minimum and maximum values.
682
+ """
683
+
684
+ n_bins = len(hist)
685
+
686
+ noise = np.random.laplace(loc=0.0, scale=1 / epsilon, size=n_bins)
687
+ hist = [val + lap_noise for val, lap_noise in zip(hist, noise)]
688
+
689
+ failure_prob = 10e-9
690
+ highest_failure_prob = 1 / (n_bins * 2)
691
+
692
+ exceeds = []
693
+ while len(exceeds) < 1 and failure_prob <= highest_failure_prob:
694
+ p = 1 - failure_prob
695
+ K = -np.log(2 - 2 * p ** (1 / (n_bins - 1))) / epsilon
696
+ exceeds = [idx for idx, v in enumerate(hist) if v > K]
697
+ failure_prob *= 10
698
+
699
+ if len(exceeds) == 0:
700
+ return (None, None)
701
+
702
+ lower_bin, upper_bin = min(exceeds), max(exceeds)
703
+ lower, _ = _get_log_histogram_edges(lower_bin)
704
+ _, upper = _get_log_histogram_edges(upper_bin)
705
+ return (float(lower), float(upper))
706
+
707
+
708
+ def _dp_bounded_quantiles(
709
+ values: np.ndarray, quantiles: list[float], epsilon: float, lower: float, upper: float
710
+ ) -> list[float]:
711
+ """
712
+ Estimate the quantile.
713
+ from: http://cs-people.bu.edu/ads22/pubs/2011/stoc194-smith.pdf
714
+
715
+ Args:
716
+ values (np.ndarray): A 1D array of numeric values.
717
+ quantiles (list[float]): List of probabilities of the quantiles to estimate.
718
+ epsilon (float): Privacy budget.
719
+ lower (float): A bounding parameter. The quantile will be estimated only for values greater than or equal to this bound.
720
+ upper (float): A bounding parameter. The quantile will be estimated only for values less than or equal to this bound.
721
+
722
+ Returns:
723
+ list[float]: The estimated quantile.
724
+ """
725
+
726
+ _LOG.info(f"compute DP bounded quantiles within [{lower}, {upper}]")
727
+ results = []
728
+ eps_part = epsilon / len(quantiles)
729
+ k = len(values)
730
+ values = np.clip(values, lower, upper)
731
+ values = np.sort(values)
732
+ for q in quantiles:
733
+ Z = np.concatenate(([lower], values, [upper]))
734
+ Z -= lower # shift right to be 0 bounded
735
+ y = np.exp(-eps_part * np.abs(np.arange(len(Z) - 1) - q * k)) * (Z[1:] - Z[:-1])
736
+ y_sum = y.sum()
737
+ p = y / y_sum if y_sum > 0 else np.ones(len(y)) / len(y) # use uniform distribution if y_sum is zero
738
+ idx = np.random.choice(range(k + 1), 1, False, p)[0]
739
+ v = np.random.uniform(Z[idx], Z[idx + 1])
740
+ results.append(v + lower)
741
+
742
+ # ensure monotonicity of results with respect to quantiles
743
+ sorted_indices = [t[0] for t in sorted(enumerate(quantiles), key=lambda x: x[1])]
744
+ sorted_results = sorted(results)
745
+ results = [sorted_results[sorted_indices.index(i)] for i in range(len(quantiles))]
746
+
747
+ return results
748
+
749
+
750
+ # NOTE: the unbounded method is not used in the current implementation
751
+ # def _dp_unbounded_quantiles(
752
+ # values: np.ndarray, quantiles: list[float], epsilon: float, beta: float = 1.01
753
+ # ) -> tuple[list[float], float]:
754
+ # """
755
+ # Fully unbounded differentially private quantile estimation using two AboveThreshold calls
756
+ # with Exponential noise (one-sided Laplace).
757
+
758
+ # Implements Algorithm 4 from Durfee (2023):
759
+ # 1) AboveThreshold on positives: T1 = q*n, f_i = |{x_j + 1 < beta^i}|
760
+ # 2) AboveThreshold on negatives: T2 = (1-q)*n, f_i = |{x_j - 1 > -beta^i}|
761
+ # 3) If first halts at k>0: return beta^k - 1
762
+ # 4) If second halts at k>0: return -beta^k + 1
763
+ # 5) Otherwise return 0
764
+
765
+ # Args:
766
+ # values (np.ndarray): A 1D array of numeric values.
767
+ # quantiles (list[float]): List of probabilities of the quantiles to estimate.
768
+ # epsilon (float): Privacy budget.
769
+ # beta (float): Multiplicative step size (default 1.01). Section 6.4 from Durfee (2023) suggests the range [1.01, 1.001] and 1.01 for general use, especially for more significant
770
+ # decreases in epsilon or in the data size.
771
+
772
+ # Returns:
773
+ # list[float]: Differentially private estimates of the quantiles.
774
+ # """
775
+
776
+ # def above_threshold(
777
+ # values: np.ndarray, q: float, eps: float, beta: float, is_positive_side: bool
778
+ # ) -> tuple[int, float]:
779
+ # n = len(values)
780
+ # eps1 = eps2 = eps / 2.0
781
+ # T = q * n if is_positive_side else (1 - q) * n
782
+ # noisy_T = T + np.random.exponential(scale=1 / eps1)
783
+ # i = 0
784
+ # while True:
785
+ # candidate = beta**i - 1 if is_positive_side else -(beta**i - 1)
786
+ # f_i = (values < candidate).sum() if is_positive_side else (values > candidate).sum()
787
+ # noisy_f_i = f_i + np.random.exponential(scale=1 / eps2)
788
+ # if noisy_f_i >= noisy_T:
789
+ # return i, candidate
790
+ # i += 1
791
+
792
+ # _LOG.info("compute DP unbounded quantiles")
793
+ # # Split epsilon across quantiles and the two AboveThreshold calls per quantile
794
+ # eps_pass = epsilon / len(quantiles) / 2.0
795
+
796
+ # results = []
797
+ # for q in quantiles:
798
+ # # 1) Positive-side AboveThreshold
799
+ # k, candidate = above_threshold(values, q, eps_pass, beta, is_positive_side=True)
800
+ # if k > 0:
801
+ # results.append(candidate)
802
+ # else:
803
+ # # 2) Continue with negative-side AboveThreshold only if the first one did not halt at k > 0
804
+ # k, candidate = above_threshold(values, q, eps_pass, beta, is_positive_side=False)
805
+ # if k > 0:
806
+ # results.append(candidate)
807
+ # else:
808
+ # # 3) Return 0 if both AboveThreshold calls did not halt at k > 0
809
+ # results.append(0.0)
810
+
811
+ # # ensure monotonicity of results with respect to quantiles
812
+ # sorted_indices = [t[0] for t in sorted(enumerate(quantiles), key=lambda x: x[1])]
813
+ # sorted_results = sorted(results)
814
+ # results = [sorted_results[sorted_indices.index(i)] for i in range(len(quantiles))]
815
+
816
+ # # NOTE: consider returning the actual epsilon spent in the future, so that the unused budget can be used for training later
817
+ # return results
818
+
819
+
820
+ def dp_quantiles(values: list | np.ndarray, quantiles: list[float], epsilon: float) -> list[float]:
821
+ """
822
+ Differentially private quantile estimation.
823
+ First, estimate the bounds of the values, then use the bounds to estimate the quantiles.
824
+ If the bounds are not available, estimate the quantiles using the unbounded method.
825
+
826
+ Args:
827
+ values (list | np.ndarray): A list of numeric values.
828
+ quantiles (list[float]): List of probabilities of the quantiles to estimate.
829
+ epsilon (float): Privacy budget.
830
+
831
+ Returns:
832
+ list[float]: The estimated quantiles.
833
+ """
834
+ values = np.array(values)
835
+
836
+ # split epsilon in (m + 1) parts for m quantiles and 1 for the bounds
837
+ m = len(quantiles)
838
+ eps_bounds = epsilon / (m + 1)
839
+ eps_quantiles = epsilon - eps_bounds
840
+
841
+ # get the bounds
842
+ # for too small values of epsilon and/or sample size this can return None
843
+ hist = compute_log_histogram(values)
844
+ lower, upper = dp_approx_bounds(hist, eps_bounds)
845
+
846
+ if lower is None or upper is None:
847
+ return [None] * len(quantiles)
848
+ return _dp_bounded_quantiles(values=values, quantiles=quantiles, epsilon=eps_quantiles, lower=lower, upper=upper)
849
+
850
+
851
+ def dp_non_rare(value_counts: dict[str, int], epsilon: float, threshold: int = 5) -> tuple[list[str], float]:
852
+ """
853
+ Differentially private selection of all categories whose true count >= threshold,
854
+ via the Laplace vector mechanism + post-processing.
855
+
856
+ Args:
857
+ value_counts (dict): Mapping from category to its count.
858
+ epsilon (float): Privacy budget.
859
+ threshold (int): Threshold for non-rare values.
860
+
861
+ Returns:
862
+ list[str]: Categories whose noisy counts are above the threshold (DP guarantee: ε-DP).
863
+ float: Non-rare ratio (DP guarantee: ε-DP).
864
+ """
865
+
866
+ # 1. Add independent Laplace(1/ε) noise to each count (vector Laplace mechanism)
867
+ # Note: sensitivity of the count vector is 1 in L1 norm
868
+ noise = np.random.laplace(loc=0.0, scale=1 / epsilon, size=len(value_counts))
869
+ noisy_counts = np.clip(np.array(list(value_counts.values())) + noise, 0, None).astype(int)
870
+ for i, cat in enumerate(value_counts):
871
+ value_counts[cat] = noisy_counts[i]
872
+ total_counts = sum(value_counts.values())
873
+
874
+ # 2. Collect all categories whose noisy count >= threshold
875
+ selected = {cat: nc for cat, nc in value_counts.items() if nc >= threshold}
876
+
877
+ # 3. Compute the non-rare ratio
878
+ noisy_total_counts = sum(selected.values())
879
+ non_rare_ratio = noisy_total_counts / total_counts
880
+
881
+ return list(selected.keys()), non_rare_ratio
882
+
883
+
884
+ def get_stochastic_rare_threshold(min_threshold: int = 5, noise_multiplier: float = 3) -> int:
885
+ return min_threshold + int(noise_multiplier * np.random.uniform())
886
+
887
+
888
+ def set_random_state(random_state: int | None = None, worker: bool = False):
889
+ def get_random_int_from_os() -> int:
890
+ # 32-bit, cryptographically secure random int from os
891
+ return int(struct.unpack("I", os.urandom(4))[0])
892
+
893
+ if worker: # worker process
894
+ if "MOSTLYAI_ENGINE_SEED" in os.environ:
895
+ random_state = int(os.environ["MOSTLYAI_ENGINE_SEED"])
896
+ else:
897
+ # normally, the seed should have been set in the main process
898
+ # but if not (e.g. in tests), we fallback to generating a random seed here
899
+ random_state = get_random_int_from_os()
900
+ else: # main process
901
+ if random_state is not None:
902
+ _LOG.info(f"Global random_state set to `{random_state}`")
903
+
904
+ if random_state is None:
905
+ random_state = get_random_int_from_os()
906
+
907
+ os.environ["MOSTLYAI_ENGINE_SEED"] = str(random_state)
908
+
909
+ import random
910
+ import numpy as np
911
+ import torch
912
+
913
+ random.seed(random_state)
914
+ np.random.seed(random_state)
915
+ torch.manual_seed(random_state)
916
+ torch.cuda.manual_seed_all(random_state)
@@ -16,45 +16,32 @@
16
16
  Categorical encoding for language models.
17
17
  """
18
18
 
19
- import numpy as np
20
19
  import pandas as pd
21
20
 
22
21
  from mostlyai.engine._common import safe_convert_string, STRING
22
+ from mostlyai.engine._encoding_types.tabular.categorical import analyze_categorical, analyze_reduce_categorical
23
23
 
24
24
  CATEGORICAL_UNKNOWN_TOKEN = "_RARE_"
25
25
 
26
26
 
27
27
  def analyze_language_categorical(values: pd.Series, root_keys: pd.Series, _: pd.Series | None = None) -> dict:
28
- values = safe_convert_string(values)
29
- # count distinct root_keys per categorical value for rare-category protection
30
- df = pd.concat([root_keys, values], axis=1)
31
- cnt_values = df.groupby(values.name)[root_keys.name].nunique().to_dict()
32
- stats = {"has_nan": sum(values.isna()) > 0, "cnt_values": cnt_values}
33
- return stats
28
+ return analyze_categorical(values, root_keys, _, safe_escape=False)
34
29
 
35
30
 
36
- def analyze_reduce_language_categorical(stats_list: list[dict], value_protection: bool = True) -> dict:
37
- # sum up all counts for each categorical value
38
- cnt_values: dict[str, int] = {}
39
- for item in stats_list:
40
- for value, count in item["cnt_values"].items():
41
- cnt_values[value] = cnt_values.get(value, 0) + count
42
- # create alphabetically sorted list of non-rare categories
43
- known_categories = [k for k in sorted(cnt_values.keys())]
44
- if value_protection:
45
- # stochastic threshold for rare categories
46
- rare_min = 5 + int(3 * np.random.uniform())
47
- else:
48
- rare_min = 0
49
- categories = [k for k in known_categories if cnt_values[k] >= rare_min]
50
- no_of_rare_categories = len(known_categories) - len(categories)
51
- # add None to categories, if any are present
31
+ def analyze_reduce_language_categorical(
32
+ stats_list: list[dict],
33
+ value_protection: bool = True,
34
+ value_protection_epsilon: float | None = None,
35
+ ) -> dict:
36
+ stats = analyze_reduce_categorical(stats_list, value_protection, value_protection_epsilon)
37
+ stats["categories"] = list(stats["codes"].keys())
52
38
  if any([j["has_nan"] for j in stats_list]):
53
- categories = [None] + categories
54
- # add special token for UNKNOWN categories at first position
55
- if no_of_rare_categories > 0:
56
- categories = [CATEGORICAL_UNKNOWN_TOKEN] + categories
57
- stats = {"no_of_rare_categories": no_of_rare_categories, "categories": categories}
39
+ # when has_nan, tabular stats are like [CATEGORICAL_UNKNOWN_TOKEN, CATEGORICAL_NULL_TOKEN, ...]
40
+ # and we need to replace CATEGORICAL_NULL_TOKEN with None for language
41
+ stats["categories"][1] = None
42
+ # drop tabular stats
43
+ stats.pop("codes")
44
+ stats.pop("cardinalities")
58
45
  return stats
59
46
 
60
47
 
@@ -16,90 +16,102 @@ import calendar
16
16
  import numpy as np
17
17
  import pandas as pd
18
18
 
19
- from mostlyai.engine._common import safe_convert_datetime
19
+ from mostlyai.engine._common import (
20
+ ANALYZE_MIN_MAX_TOP_N,
21
+ ANALYZE_REDUCE_MIN_MAX_N,
22
+ compute_log_histogram,
23
+ dp_approx_bounds,
24
+ get_stochastic_rare_threshold,
25
+ safe_convert_datetime,
26
+ )
20
27
 
21
28
 
22
29
  def analyze_language_datetime(values: pd.Series, root_keys: pd.Series, _: pd.Series | None = None) -> dict:
23
30
  values = safe_convert_datetime(values)
31
+ # compute log histogram for DP bounds
32
+ log_hist = compute_log_histogram(values.dropna().astype("int64"))
33
+
24
34
  df = pd.concat([root_keys, values], axis=1)
25
35
  # determine lowest/highest values by root ID, and return Top 10
26
36
  min_dates = df.groupby(root_keys.name)[values.name].min().dropna()
27
- min11 = min_dates.sort_values(ascending=True).head(11).astype(str).tolist()
37
+ min_n = min_dates.sort_values(ascending=True).head(ANALYZE_MIN_MAX_TOP_N).astype(str).tolist()
28
38
  max_dates = df.groupby(root_keys.name)[values.name].max().dropna()
29
- max11 = max_dates.sort_values(ascending=False).head(11).astype(str).tolist()
39
+ max_n = max_dates.sort_values(ascending=False).head(ANALYZE_MIN_MAX_TOP_N).astype(str).tolist()
30
40
  # determine if there are any NaN values
31
41
  has_nan = bool(values.isna().any())
32
42
  # return stats
33
43
  stats = {
34
44
  "has_nan": has_nan,
35
- "min11": min11,
36
- "max11": max11,
45
+ "min_n": min_n,
46
+ "max_n": max_n,
47
+ "log_hist": log_hist,
37
48
  }
38
49
  return stats
39
50
 
40
51
 
41
- def analyze_reduce_language_datetime(stats_list: list[dict], value_protection: bool = True) -> dict:
52
+ def analyze_reduce_language_datetime(
53
+ stats_list: list[dict],
54
+ value_protection: bool = True,
55
+ value_protection_epsilon: float | None = None,
56
+ ) -> dict:
42
57
  # check if there are missing values
43
58
  has_nan = any([j["has_nan"] for j in stats_list])
44
- # determine min / max 5 values to map too low / too high values to
45
- min11 = sorted([v for min11 in [j["min11"] for j in stats_list] for v in min11], reverse=False)[:11]
46
- max11 = sorted([v for max11 in [j["max11"] for j in stats_list] for v in max11], reverse=True)[:11]
59
+ reduced_min_n = sorted([v for min_n in [j["min_n"] for j in stats_list] for v in min_n], reverse=False)
60
+ reduced_max_n = sorted([v for max_n in [j["max_n"] for j in stats_list] for v in max_n], reverse=True)
47
61
  if value_protection:
48
- # extreme value protection - discard lowest/highest 5 values
49
- if len(min11) < 11 or len(max11) < 11:
50
- # less than 11 subjects with non-NULL values; we need to protect all
51
- min5 = []
52
- max5 = []
62
+ if len(reduced_min_n) < ANALYZE_REDUCE_MIN_MAX_N or len(reduced_max_n) < ANALYZE_REDUCE_MIN_MAX_N:
63
+ # protect all values if there are less than ANALYZE_REDUCE_MIN_MAX_N values
64
+ reduced_min = None
65
+ reduced_max = None
53
66
  else:
54
- min5 = [str(v) for v in min11[5:10]] # drop 1 to 5th lowest; keep 6th to 10th lowest
55
- max5 = [str(v) for v in max11[5:10]] # drop 1 to 5th highest; keep 6th to 10th highest
67
+ if value_protection_epsilon is not None:
68
+ if any(len(v) > 10 for v in reduced_min_n + reduced_max_n):
69
+ dt_format = "%Y-%m-%d %H:%M:%S"
70
+ else:
71
+ dt_format = "%Y-%m-%d"
72
+ # Sum up log histograms bin-wise from all partitions
73
+ log_hist = [sum(bin) for bin in zip(*[j["log_hist"] for j in stats_list])]
74
+ reduced_min, reduced_max = dp_approx_bounds(log_hist, value_protection_epsilon)
75
+ if reduced_min is not None and reduced_max is not None:
76
+ # convert back to the original string format
77
+ reduced_min = pd.to_datetime(int(reduced_min), unit="us").strftime(dt_format)
78
+ reduced_max = pd.to_datetime(int(reduced_max), unit="us").strftime(dt_format)
79
+ else:
80
+ reduced_min = str(reduced_min_n[get_stochastic_rare_threshold(min_threshold=5)])
81
+ reduced_max = str(reduced_max_n[get_stochastic_rare_threshold(min_threshold=5)])
56
82
  else:
57
- min5 = min11[0:4]
58
- max5 = max11[0:4]
83
+ reduced_min = str(reduced_min_n[0]) if len(reduced_min_n) > 0 else None
84
+ reduced_max = str(reduced_max_n[0]) if len(reduced_max_n) > 0 else None
59
85
  stats = {
60
86
  "has_nan": has_nan,
61
- "min5": min5,
62
- "max5": max5,
87
+ "min": reduced_min,
88
+ "max": reduced_max,
63
89
  }
64
90
  return stats
65
91
 
66
92
 
93
+ def _clip_datetime(values: pd.Series, stats: dict) -> pd.Series:
94
+ if stats["min"] is not None:
95
+ reduced_min = np.datetime64(stats["min"], "ns")
96
+ values.loc[values < reduced_min] = reduced_min
97
+ if stats["max"] is not None:
98
+ reduced_max = np.datetime64(stats["max"], "ns")
99
+ values.loc[values > reduced_max] = reduced_max
100
+ return values
101
+
102
+
67
103
  def encode_language_datetime(values: pd.Series, stats: dict, _: pd.Series | None = None) -> pd.Series:
68
104
  # convert
69
105
  values = safe_convert_datetime(values)
70
106
  values = values.copy()
71
107
  # reset index, as `values.mask` can throw errors for misaligned indices
72
108
  values.reset_index(drop=True, inplace=True)
73
- # replace extreme values with randomly sampled 5-th to 10-th largest/smallest values
74
- min5 = stats["min5"] if len(stats["min5"]) > 0 else [0]
75
- max5 = stats["max5"] if len(stats["max5"]) > 0 else [0]
76
- min5 = pd.Series(min5, dtype=values.dtype)
77
- max5 = pd.Series(max5, dtype=values.dtype)
78
- values.mask(
79
- values < min5[0],
80
- min5.sample(n=len(values), replace=True, ignore_index=True),
81
- inplace=True,
82
- )
83
- values.mask(
84
- values > max5[0],
85
- max5.sample(n=len(values), replace=True, ignore_index=True),
86
- inplace=True,
87
- )
109
+ # replace extreme values with min/max
110
+ values = _clip_datetime(values, stats)
88
111
  return values
89
112
 
90
113
 
91
- def _clip_datetime(x: pd.Series, min5: list, max5: list) -> pd.Series:
92
- x_dt = pd.to_datetime(x, errors="coerce")
93
- min_arr = pd.to_datetime(min5).to_numpy(dtype="datetime64[ns]")
94
- max_arr = pd.to_datetime(max5).to_numpy(dtype="datetime64[ns]")
95
- n = len(x_dt)
96
- random_mins = np.random.choice(min_arr, size=n)
97
- random_maxs = np.random.choice(max_arr, size=n)
98
- clipped = np.minimum(np.maximum(x_dt.to_numpy(dtype="datetime64[ns]"), random_mins), random_maxs)
99
- return pd.Series(clipped, index=x.index)
100
-
101
-
102
- def decode_language_datetime(x: pd.Series, col_stats: dict[str, str]) -> pd.Series:
114
+ def decode_language_datetime(x: pd.Series, stats: dict[str, str]) -> pd.Series:
103
115
  x = x.where(~x.isin(["", "_INVALID_"]), np.nan)
104
116
 
105
117
  valid_mask = (
@@ -139,5 +151,5 @@ def decode_language_datetime(x: pd.Series, col_stats: dict[str, str]) -> pd.Seri
139
151
  x.loc[valid_mask] = new_date
140
152
 
141
153
  x = pd.to_datetime(x, errors="coerce")
142
- x = _clip_datetime(x, col_stats["min5"], col_stats["max5"])
154
+ x = _clip_datetime(x, stats)
143
155
  return x.astype("datetime64[ns]")