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.
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/PKG-INFO +1 -1
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/__init__.py +1 -1
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_common.py +306 -2
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/language/categorical.py +15 -28
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/language/datetime.py +60 -48
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/language/numeric.py +56 -49
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/language/text.py +5 -1
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/tabular/categorical.py +20 -11
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/tabular/character.py +14 -8
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/tabular/datetime.py +63 -45
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/tabular/itt.py +53 -28
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/tabular/lat_long.py +23 -12
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/tabular/numeric.py +79 -64
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/encoding.py +13 -2
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/generation.py +3 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/training.py +26 -17
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/xgrammar_utils.py +4 -4
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_tabular/encoding.py +4 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_tabular/generation.py +9 -2
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_tabular/training.py +25 -16
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/analysis.py +110 -81
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/domain.py +18 -9
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/encoding.py +9 -3
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/generation.py +4 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/splitting.py +10 -4
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/training.py +5 -1
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/pyproject.toml +1 -1
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/.gitignore +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/LICENSE +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/README.md +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_dtypes.py +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/__init__.py +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/language/__init__.py +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/tabular/__init__.py +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/__init__.py +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/common.py +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/engine/__init__.py +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/engine/base.py +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/engine/hf_engine.py +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/engine/vllm_engine.py +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/lstm.py +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_language/tokenizer_utils.py +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_memory.py +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_tabular/__init__.py +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_tabular/argn.py +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_tabular/common.py +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_tabular/fairness.py +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_training_utils.py +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_workspace.py +0 -0
- {mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/logging.py +0 -0
|
@@ -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.
|
|
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 =
|
|
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(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
stats
|
|
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
|
|
{mostlyai_engine-1.3.3 → mostlyai_engine-1.4.0}/mostlyai/engine/_encoding_types/language/datetime.py
RENAMED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
36
|
-
"
|
|
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(
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
"
|
|
62
|
-
"
|
|
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
|
|
74
|
-
|
|
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
|
|
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,
|
|
154
|
+
x = _clip_datetime(x, stats)
|
|
143
155
|
return x.astype("datetime64[ns]")
|