mostlyai-engine 1.3.3__tar.gz → 1.4.1__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.3.3 → mostlyai_engine-1.4.1}/PKG-INFO +1 -1
  2. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/__init__.py +3 -2
  3. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_common.py +273 -2
  4. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_encoding_types/language/categorical.py +15 -28
  5. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_encoding_types/language/datetime.py +60 -48
  6. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_encoding_types/language/numeric.py +56 -49
  7. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_encoding_types/language/text.py +5 -1
  8. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_encoding_types/tabular/categorical.py +20 -11
  9. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_encoding_types/tabular/character.py +14 -8
  10. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_encoding_types/tabular/datetime.py +63 -45
  11. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_encoding_types/tabular/itt.py +53 -28
  12. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_encoding_types/tabular/lat_long.py +23 -12
  13. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_encoding_types/tabular/numeric.py +79 -64
  14. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_language/encoding.py +10 -2
  15. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_language/training.py +32 -21
  16. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_language/xgrammar_utils.py +4 -4
  17. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_tabular/encoding.py +2 -0
  18. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_tabular/generation.py +6 -2
  19. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_tabular/training.py +22 -16
  20. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/analysis.py +108 -81
  21. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/domain.py +18 -9
  22. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/encoding.py +5 -1
  23. mostlyai_engine-1.4.1/mostlyai/engine/random_state.py +49 -0
  24. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/splitting.py +6 -3
  25. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/training.py +1 -1
  26. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/pyproject.toml +1 -1
  27. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/.gitignore +0 -0
  28. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/LICENSE +0 -0
  29. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/README.md +0 -0
  30. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_dtypes.py +0 -0
  31. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_encoding_types/__init__.py +0 -0
  32. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_encoding_types/language/__init__.py +0 -0
  33. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_encoding_types/tabular/__init__.py +0 -0
  34. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_language/__init__.py +0 -0
  35. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_language/common.py +0 -0
  36. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_language/engine/__init__.py +0 -0
  37. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_language/engine/base.py +0 -0
  38. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_language/engine/hf_engine.py +0 -0
  39. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_language/engine/vllm_engine.py +0 -0
  40. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_language/generation.py +0 -0
  41. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_language/lstm.py +0 -0
  42. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_language/tokenizer_utils.py +0 -0
  43. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_memory.py +0 -0
  44. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_tabular/__init__.py +0 -0
  45. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_tabular/argn.py +0 -0
  46. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_tabular/common.py +0 -0
  47. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_tabular/fairness.py +0 -0
  48. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_training_utils.py +0 -0
  49. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/_workspace.py +0 -0
  50. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/mostlyai/engine/generation.py +0 -0
  51. {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.1}/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.1
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
@@ -14,6 +14,7 @@
14
14
  import warnings
15
15
 
16
16
  from mostlyai.engine.logging import init_logging
17
+ from mostlyai.engine.random_state import set_random_state
17
18
  from mostlyai.engine.splitting import split
18
19
  from mostlyai.engine.analysis import analyze
19
20
  from mostlyai.engine.training import train
@@ -21,8 +22,8 @@ from mostlyai.engine.encoding import encode
21
22
  from mostlyai.engine.generation import generate
22
23
 
23
24
 
24
- __all__ = ["split", "analyze", "encode", "train", "generate", "init_logging"]
25
- __version__ = "1.3.3"
25
+ __all__ = ["split", "analyze", "encode", "train", "generate", "init_logging", "set_random_state"]
26
+ __version__ = "1.4.1"
26
27
 
27
28
  # suppress specific warning related to os.fork() in multi-threaded processes
28
29
  warnings.filterwarnings("ignore", category=DeprecationWarning, message=".*multi-threaded.*fork.*")
@@ -57,6 +57,12 @@ SIDX_SUB_COLUMN_PREFIX = f"{SLEN_SIDX_SDEC_COLUMN}{PREFIX_SUB_COLUMN}sidx_" # s
57
57
  SDEC_SUB_COLUMN_PREFIX = f"{SLEN_SIDX_SDEC_COLUMN}{PREFIX_SUB_COLUMN}sdec_" # sequence index decile
58
58
  TABLE_COLUMN_INFIX = "::" # this should be consistent as in mostly-data and mostlyai-qa
59
59
 
60
+ ANALYZE_MIN_MAX_TOP_N = 1000 # the number of min/max values to be kept from each partition
61
+
62
+ # the minimal number of min/max values to trigger the reduction; if less, the min/max will be reduced to None
63
+ # this should be at least greater than the non-DP stochastic threshold for rare value protection (5 + noise)
64
+ ANALYZE_REDUCE_MIN_MAX_N = 20
65
+
60
66
  TEMPORARY_PRIMARY_KEY = "__primary_key"
61
67
 
62
68
  STRING = "string[pyarrow]" # This utilizes pyarrow's large string type since pandas 2.2
@@ -431,14 +437,12 @@ def get_sequence_length_stats(stats: dict) -> dict:
431
437
  "min": stats["seq_len"]["min"],
432
438
  "median": stats["seq_len"]["median"],
433
439
  "max": stats["seq_len"]["max"],
434
- "deciles": stats["seq_len"]["deciles"],
435
440
  }
436
441
  else:
437
442
  stats = {
438
443
  "min": 1,
439
444
  "median": 1,
440
445
  "max": 1,
441
- "deciles": [1 for i in range(11)],
442
446
  }
443
447
  return stats
444
448
 
@@ -610,3 +614,270 @@ class FixedSizeSampleBuffer:
610
614
  self.buffer = []
611
615
  self.current_size = 0
612
616
  self.n_clears += 1
617
+
618
+
619
+ def _get_log_histogram_edges(idx: int, bins: int = 64) -> tuple[float, float]:
620
+ """
621
+ Modified from OpenDP's SmartNoise SDK (MIT License)
622
+ Source: https://github.com/opendp/smartnoise-sdk/blob/main/sql/snsql/sql/_mechanisms/approx_bounds.py
623
+ """
624
+ if idx == bins:
625
+ return (0.0, 1.0)
626
+ elif idx > bins:
627
+ return (2.0 ** (idx - bins - 1), 2.0 ** (idx - bins))
628
+ elif idx == bins - 1:
629
+ return (-1.0, -0.0)
630
+ else:
631
+ return (-1 * 2.0 ** np.abs(bins - idx - 1), -1 * 2.0 ** np.abs(bins - idx - 2))
632
+
633
+
634
+ def compute_log_histogram(values: np.ndarray, bins: int = 64) -> list[int]:
635
+ """
636
+ Modified from OpenDP's SmartNoise SDK (MIT License)
637
+ Source: https://github.com/opendp/smartnoise-sdk/blob/main/sql/snsql/sql/_mechanisms/approx_bounds.py
638
+ """
639
+ hist = [0.0] * bins * 2
640
+
641
+ values = np.array(values, dtype=np.float64)
642
+ values = values[values != np.inf]
643
+ values = values[values != -np.inf]
644
+ values = values[~np.isnan(values)]
645
+ edge_list = [_get_log_histogram_edges(idx) for idx in range(len(hist))]
646
+ min_val = min([lower for lower, _ in edge_list])
647
+ max_val = max([upper for _, upper in edge_list]) - 1
648
+ values = np.clip(values, min_val, max_val)
649
+
650
+ # compute histograms
651
+ for v in values:
652
+ bin = None
653
+ for idx, (lower, upper) in enumerate(edge_list):
654
+ if lower <= v < upper:
655
+ bin = idx
656
+ break
657
+ if bin is None:
658
+ bin = idx
659
+ hist[bin] += 1
660
+
661
+ # for testing
662
+ lower, upper = _get_log_histogram_edges(bin)
663
+ return hist
664
+
665
+
666
+ def dp_approx_bounds(hist: list[int], epsilon: float) -> tuple[float | None, float | None]:
667
+ """
668
+ Modified from OpenDP's SmartNoise SDK (MIT License)
669
+ Source: https://github.com/opendp/smartnoise-sdk/blob/main/sql/snsql/sql/_mechanisms/approx_bounds.py
670
+
671
+ Estimate the minimium and maximum values of a list of values.
672
+ from: https://desfontain.es/thesis/Usability.html#usability-u-ding-
673
+
674
+ Args:
675
+ hist (list[int]): A list of log histogram counts.
676
+ epsilon (float): The privacy budget to spend estimating the bounds.
677
+
678
+ Returns:
679
+ tuple[float | None, float | None]: A tuple of the estimated minimum and maximum values.
680
+ """
681
+
682
+ n_bins = len(hist)
683
+
684
+ noise = np.random.laplace(loc=0.0, scale=1 / epsilon, size=n_bins)
685
+ hist = [val + lap_noise for val, lap_noise in zip(hist, noise)]
686
+
687
+ failure_prob = 10e-9
688
+ highest_failure_prob = 1 / (n_bins * 2)
689
+
690
+ exceeds = []
691
+ while len(exceeds) < 1 and failure_prob <= highest_failure_prob:
692
+ p = 1 - failure_prob
693
+ K = -np.log(2 - 2 * p ** (1 / (n_bins - 1))) / epsilon
694
+ exceeds = [idx for idx, v in enumerate(hist) if v > K]
695
+ failure_prob *= 10
696
+
697
+ if len(exceeds) == 0:
698
+ return (None, None)
699
+
700
+ lower_bin, upper_bin = min(exceeds), max(exceeds)
701
+ lower, _ = _get_log_histogram_edges(lower_bin)
702
+ _, upper = _get_log_histogram_edges(upper_bin)
703
+ return (float(lower), float(upper))
704
+
705
+
706
+ def _dp_bounded_quantiles(
707
+ values: np.ndarray, quantiles: list[float], epsilon: float, lower: float, upper: float
708
+ ) -> list[float]:
709
+ """
710
+ Estimate the quantile.
711
+ from: http://cs-people.bu.edu/ads22/pubs/2011/stoc194-smith.pdf
712
+
713
+ Args:
714
+ values (np.ndarray): A 1D array of numeric values.
715
+ quantiles (list[float]): List of probabilities of the quantiles to estimate.
716
+ epsilon (float): Privacy budget.
717
+ lower (float): A bounding parameter. The quantile will be estimated only for values greater than or equal to this bound.
718
+ upper (float): A bounding parameter. The quantile will be estimated only for values less than or equal to this bound.
719
+
720
+ Returns:
721
+ list[float]: The estimated quantile.
722
+ """
723
+
724
+ _LOG.info(f"compute DP bounded quantiles within [{lower}, {upper}]")
725
+ results = []
726
+ eps_part = epsilon / len(quantiles)
727
+ k = len(values)
728
+ values = np.clip(values, lower, upper)
729
+ values = np.sort(values)
730
+ for q in quantiles:
731
+ Z = np.concatenate(([lower], values, [upper]))
732
+ Z -= lower # shift right to be 0 bounded
733
+ y = np.exp(-eps_part * np.abs(np.arange(len(Z) - 1) - q * k)) * (Z[1:] - Z[:-1])
734
+ y_sum = y.sum()
735
+ p = y / y_sum if y_sum > 0 else np.ones(len(y)) / len(y) # use uniform distribution if y_sum is zero
736
+ idx = np.random.choice(range(k + 1), 1, False, p)[0]
737
+ v = np.random.uniform(Z[idx], Z[idx + 1])
738
+ results.append(v + lower)
739
+
740
+ # ensure monotonicity of results with respect to quantiles
741
+ sorted_indices = [t[0] for t in sorted(enumerate(quantiles), key=lambda x: x[1])]
742
+ sorted_results = sorted(results)
743
+ results = [sorted_results[sorted_indices.index(i)] for i in range(len(quantiles))]
744
+
745
+ return results
746
+
747
+
748
+ # NOTE: the unbounded method is not used in the current implementation
749
+ # def _dp_unbounded_quantiles(
750
+ # values: np.ndarray, quantiles: list[float], epsilon: float, beta: float = 1.01
751
+ # ) -> tuple[list[float], float]:
752
+ # """
753
+ # Fully unbounded differentially private quantile estimation using two AboveThreshold calls
754
+ # with Exponential noise (one-sided Laplace).
755
+
756
+ # Implements Algorithm 4 from Durfee (2023):
757
+ # 1) AboveThreshold on positives: T1 = q*n, f_i = |{x_j + 1 < beta^i}|
758
+ # 2) AboveThreshold on negatives: T2 = (1-q)*n, f_i = |{x_j - 1 > -beta^i}|
759
+ # 3) If first halts at k>0: return beta^k - 1
760
+ # 4) If second halts at k>0: return -beta^k + 1
761
+ # 5) Otherwise return 0
762
+
763
+ # Args:
764
+ # values (np.ndarray): A 1D array of numeric values.
765
+ # quantiles (list[float]): List of probabilities of the quantiles to estimate.
766
+ # epsilon (float): Privacy budget.
767
+ # 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
768
+ # decreases in epsilon or in the data size.
769
+
770
+ # Returns:
771
+ # list[float]: Differentially private estimates of the quantiles.
772
+ # """
773
+
774
+ # def above_threshold(
775
+ # values: np.ndarray, q: float, eps: float, beta: float, is_positive_side: bool
776
+ # ) -> tuple[int, float]:
777
+ # n = len(values)
778
+ # eps1 = eps2 = eps / 2.0
779
+ # T = q * n if is_positive_side else (1 - q) * n
780
+ # noisy_T = T + np.random.exponential(scale=1 / eps1)
781
+ # i = 0
782
+ # while True:
783
+ # candidate = beta**i - 1 if is_positive_side else -(beta**i - 1)
784
+ # f_i = (values < candidate).sum() if is_positive_side else (values > candidate).sum()
785
+ # noisy_f_i = f_i + np.random.exponential(scale=1 / eps2)
786
+ # if noisy_f_i >= noisy_T:
787
+ # return i, candidate
788
+ # i += 1
789
+
790
+ # _LOG.info("compute DP unbounded quantiles")
791
+ # # Split epsilon across quantiles and the two AboveThreshold calls per quantile
792
+ # eps_pass = epsilon / len(quantiles) / 2.0
793
+
794
+ # results = []
795
+ # for q in quantiles:
796
+ # # 1) Positive-side AboveThreshold
797
+ # k, candidate = above_threshold(values, q, eps_pass, beta, is_positive_side=True)
798
+ # if k > 0:
799
+ # results.append(candidate)
800
+ # else:
801
+ # # 2) Continue with negative-side AboveThreshold only if the first one did not halt at k > 0
802
+ # k, candidate = above_threshold(values, q, eps_pass, beta, is_positive_side=False)
803
+ # if k > 0:
804
+ # results.append(candidate)
805
+ # else:
806
+ # # 3) Return 0 if both AboveThreshold calls did not halt at k > 0
807
+ # results.append(0.0)
808
+
809
+ # # ensure monotonicity of results with respect to quantiles
810
+ # sorted_indices = [t[0] for t in sorted(enumerate(quantiles), key=lambda x: x[1])]
811
+ # sorted_results = sorted(results)
812
+ # results = [sorted_results[sorted_indices.index(i)] for i in range(len(quantiles))]
813
+
814
+ # # NOTE: consider returning the actual epsilon spent in the future, so that the unused budget can be used for training later
815
+ # return results
816
+
817
+
818
+ def dp_quantiles(values: list | np.ndarray, quantiles: list[float], epsilon: float) -> list[float]:
819
+ """
820
+ Differentially private quantile estimation.
821
+ First, estimate the bounds of the values, then use the bounds to estimate the quantiles.
822
+ If the bounds are not available, estimate the quantiles using the unbounded method.
823
+
824
+ Args:
825
+ values (list | np.ndarray): A list of numeric values.
826
+ quantiles (list[float]): List of probabilities of the quantiles to estimate.
827
+ epsilon (float): Privacy budget.
828
+
829
+ Returns:
830
+ list[float]: The estimated quantiles.
831
+ """
832
+ values = np.array(values)
833
+
834
+ # split epsilon in (m + 1) parts for m quantiles and 1 for the bounds
835
+ m = len(quantiles)
836
+ eps_bounds = epsilon / (m + 1)
837
+ eps_quantiles = epsilon - eps_bounds
838
+
839
+ # get the bounds
840
+ # for too small values of epsilon and/or sample size this can return None
841
+ hist = compute_log_histogram(values)
842
+ lower, upper = dp_approx_bounds(hist, eps_bounds)
843
+
844
+ if lower is None or upper is None:
845
+ return [None] * len(quantiles)
846
+ return _dp_bounded_quantiles(values=values, quantiles=quantiles, epsilon=eps_quantiles, lower=lower, upper=upper)
847
+
848
+
849
+ def dp_non_rare(value_counts: dict[str, int], epsilon: float, threshold: int = 5) -> tuple[list[str], float]:
850
+ """
851
+ Differentially private selection of all categories whose true count >= threshold,
852
+ via the Laplace vector mechanism + post-processing.
853
+
854
+ Args:
855
+ value_counts (dict): Mapping from category to its count.
856
+ epsilon (float): Privacy budget.
857
+ threshold (int): Threshold for non-rare values.
858
+
859
+ Returns:
860
+ list[str]: Categories whose noisy counts are above the threshold (DP guarantee: ε-DP).
861
+ float: Non-rare ratio (DP guarantee: ε-DP).
862
+ """
863
+
864
+ # 1. Add independent Laplace(1/ε) noise to each count (vector Laplace mechanism)
865
+ # Note: sensitivity of the count vector is 1 in L1 norm
866
+ noise = np.random.laplace(loc=0.0, scale=1 / epsilon, size=len(value_counts))
867
+ noisy_counts = np.clip(np.array(list(value_counts.values())) + noise, 0, None).astype(int)
868
+ for i, cat in enumerate(value_counts):
869
+ value_counts[cat] = noisy_counts[i]
870
+ total_counts = sum(value_counts.values())
871
+
872
+ # 2. Collect all categories whose noisy count >= threshold
873
+ selected = {cat: nc for cat, nc in value_counts.items() if nc >= threshold}
874
+
875
+ # 3. Compute the non-rare ratio
876
+ noisy_total_counts = sum(selected.values())
877
+ non_rare_ratio = noisy_total_counts / total_counts
878
+
879
+ return list(selected.keys()), non_rare_ratio
880
+
881
+
882
+ def get_stochastic_rare_threshold(min_threshold: int = 5, noise_multiplier: float = 3) -> int:
883
+ return min_threshold + int(noise_multiplier * np.random.uniform())
@@ -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]")