action-rules 2.0.0__tar.gz → 2.1.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. {action_rules-2.0.0 → action_rules-2.1.0}/PKG-INFO +5 -4
  2. {action_rules-2.0.0 → action_rules-2.1.0}/README.md +2 -2
  3. {action_rules-2.0.0 → action_rules-2.1.0}/pyproject.toml +2 -2
  4. {action_rules-2.0.0 → action_rules-2.1.0}/src/action_rules/__init__.py +1 -1
  5. {action_rules-2.0.0 → action_rules-2.1.0}/src/action_rules/action_rules.py +47 -9
  6. {action_rules-2.0.0 → action_rules-2.1.0}/src/action_rules/inference/analytic.py +86 -19
  7. {action_rules-2.0.0 → action_rules-2.1.0}/src/action_rules/inference/base.py +28 -0
  8. {action_rules-2.0.0 → action_rules-2.1.0}/src/action_rules/inference/bayesian.py +15 -0
  9. {action_rules-2.0.0 → action_rules-2.1.0}/src/action_rules/inference/bootstrap.py +78 -33
  10. {action_rules-2.0.0 → action_rules-2.1.0}/src/action_rules/rules/rules.py +9 -0
  11. {action_rules-2.0.0 → action_rules-2.1.0}/tests/inference/test_analytic.py +36 -0
  12. {action_rules-2.0.0 → action_rules-2.1.0}/tests/inference/test_base.py +13 -0
  13. {action_rules-2.0.0 → action_rules-2.1.0}/tests/inference/test_bayesian.py +11 -0
  14. {action_rules-2.0.0 → action_rules-2.1.0}/tests/inference/test_bootstrap.py +13 -0
  15. {action_rules-2.0.0 → action_rules-2.1.0}/tests/rules/test_rules.py +21 -0
  16. {action_rules-2.0.0 → action_rules-2.1.0}/tests/test_action_rules.py +45 -0
  17. {action_rules-2.0.0 → action_rules-2.1.0}/tests/test_confidence_intervals.py +20 -0
  18. {action_rules-2.0.0 → action_rules-2.1.0}/LICENSE +0 -0
  19. {action_rules-2.0.0 → action_rules-2.1.0}/src/action_rules/candidates/__init__.py +0 -0
  20. {action_rules-2.0.0 → action_rules-2.1.0}/src/action_rules/candidates/candidate_generator.py +0 -0
  21. {action_rules-2.0.0 → action_rules-2.1.0}/src/action_rules/cli.py +0 -0
  22. {action_rules-2.0.0 → action_rules-2.1.0}/src/action_rules/evaluation/__init__.py +0 -0
  23. {action_rules-2.0.0 → action_rules-2.1.0}/src/action_rules/evaluation/cv.py +0 -0
  24. {action_rules-2.0.0 → action_rules-2.1.0}/src/action_rules/evaluation/metrics.py +0 -0
  25. {action_rules-2.0.0 → action_rules-2.1.0}/src/action_rules/inference/__init__.py +0 -0
  26. {action_rules-2.0.0 → action_rules-2.1.0}/src/action_rules/input/__init__.py +0 -0
  27. {action_rules-2.0.0 → action_rules-2.1.0}/src/action_rules/input/input.py +0 -0
  28. {action_rules-2.0.0 → action_rules-2.1.0}/src/action_rules/output/__init__.py +0 -0
  29. {action_rules-2.0.0 → action_rules-2.1.0}/src/action_rules/output/output.py +0 -0
  30. {action_rules-2.0.0 → action_rules-2.1.0}/src/action_rules/rules/__init__.py +0 -0
  31. {action_rules-2.0.0 → action_rules-2.1.0}/src/action_rules/visualization/__init__.py +0 -0
  32. {action_rules-2.0.0 → action_rules-2.1.0}/src/action_rules/visualization/plots.py +0 -0
  33. {action_rules-2.0.0 → action_rules-2.1.0}/tests/__init__.py +0 -0
  34. {action_rules-2.0.0 → action_rules-2.1.0}/tests/candidates/__init__.py +0 -0
  35. {action_rules-2.0.0 → action_rules-2.1.0}/tests/candidates/test_candidate_generator.py +0 -0
  36. {action_rules-2.0.0 → action_rules-2.1.0}/tests/evaluation/__init__.py +0 -0
  37. {action_rules-2.0.0 → action_rules-2.1.0}/tests/evaluation/test_action_rules_method.py +0 -0
  38. {action_rules-2.0.0 → action_rules-2.1.0}/tests/evaluation/test_cv.py +0 -0
  39. {action_rules-2.0.0 → action_rules-2.1.0}/tests/evaluation/test_metrics.py +0 -0
  40. {action_rules-2.0.0 → action_rules-2.1.0}/tests/inference/__init__.py +0 -0
  41. {action_rules-2.0.0 → action_rules-2.1.0}/tests/input/__init__.py +0 -0
  42. {action_rules-2.0.0 → action_rules-2.1.0}/tests/input/test_input.py +0 -0
  43. {action_rules-2.0.0 → action_rules-2.1.0}/tests/output/__init__.py +0 -0
  44. {action_rules-2.0.0 → action_rules-2.1.0}/tests/output/test_output.py +0 -0
  45. {action_rules-2.0.0 → action_rules-2.1.0}/tests/rules/__init__.py +0 -0
  46. {action_rules-2.0.0 → action_rules-2.1.0}/tests/simulation/__init__.py +0 -0
  47. {action_rules-2.0.0 → action_rules-2.1.0}/tests/simulation/coverage_simulation.py +0 -0
  48. {action_rules-2.0.0 → action_rules-2.1.0}/tests/simulation/test_coverage_simulation.py +0 -0
  49. {action_rules-2.0.0 → action_rules-2.1.0}/tests/visualization/__init__.py +0 -0
  50. {action_rules-2.0.0 → action_rules-2.1.0}/tests/visualization/test_plot_data.py +0 -0
  51. {action_rules-2.0.0 → action_rules-2.1.0}/tests/visualization/test_plots.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: action-rules
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: The package for action rules mining using Action-Apriori (Apriori Modified for Action Rules Mining)..
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Programming Language :: Python :: 3.13
20
20
  Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Programming Language :: Python :: 3.15
21
22
  Provides-Extra: dev
22
23
  Provides-Extra: doc
23
24
  Provides-Extra: inference
@@ -42,7 +43,7 @@ Requires-Dist: pandas (>=2.2.2,<3.0.0)
42
43
  Requires-Dist: pip (>=24.0,<25.0) ; extra == "dev"
43
44
  Requires-Dist: pre-commit (>=3.7.1,<4.0.0) ; extra == "dev"
44
45
  Requires-Dist: pytest (>=8.2.1,<9.0.0) ; extra == "test"
45
- Requires-Dist: pytest-cov (>=5.0.0,<6.0.0) ; extra == "test"
46
+ Requires-Dist: pytest-cov (>=5.0.0,<8.0.0) ; extra == "test"
46
47
  Requires-Dist: scipy (>=1.10,<2.0) ; extra == "test" or extra == "inference" or extra == "viz"
47
48
  Requires-Dist: toml (>=0.10.2,<0.11.0) ; extra == "dev"
48
49
  Requires-Dist: tox (>=4.15.0,<5.0.0) ; extra == "dev"
@@ -101,9 +102,9 @@ target = 'Survived'
101
102
  min_stable_attributes = 2
102
103
  min_flexible_attributes = 1 # min 1
103
104
  min_undesired_support = 1
104
- min_undesired_confidence = 0.5 # min 0.5
105
+ min_undesired_confidence = 0.5
105
106
  min_desired_support = 1
106
- min_desired_confidence = 0.5 # min 0.5
107
+ min_desired_confidence = 0.5
107
108
  undesired_state = '0'
108
109
  desired_state = '1'
109
110
  # Action Rules Mining
@@ -48,9 +48,9 @@ target = 'Survived'
48
48
  min_stable_attributes = 2
49
49
  min_flexible_attributes = 1 # min 1
50
50
  min_undesired_support = 1
51
- min_undesired_confidence = 0.5 # min 0.5
51
+ min_undesired_confidence = 0.5
52
52
  min_desired_support = 1
53
- min_desired_confidence = 0.5 # min 0.5
53
+ min_desired_confidence = 0.5
54
54
  undesired_state = '0'
55
55
  desired_state = '1'
56
56
  # Action Rules Mining
@@ -1,7 +1,7 @@
1
1
  [tool]
2
2
  [tool.poetry]
3
3
  name = "action-rules"
4
- version = "2.0.0"
4
+ version = "2.1.0"
5
5
  homepage = "https://github.com/lukassykora/action-rules"
6
6
  description = "The package for action rules mining using Action-Apriori (Apriori Modified for Action Rules Mining).."
7
7
  authors = ["Lukas Sykora <lukas.sykora@vse.cz>"]
@@ -31,7 +31,7 @@ flake8 = { version = "^7.0.0", optional = true}
31
31
  flake8-docstrings = { version = "^1.7.0", optional = true }
32
32
  mypy = {version = "^1.10.0", optional = true}
33
33
  pytest = { version = "^8.2.1", optional = true}
34
- pytest-cov = { version = "^5.0.0", optional = true}
34
+ pytest-cov = { version = ">=5.0.0,<8.0.0", optional = true}
35
35
  scipy = { version = "^1.10", optional = true}
36
36
  matplotlib = { version = "^3.7", optional = true}
37
37
  tox = { version = "^4.15.0", optional = true}
@@ -17,4 +17,4 @@ __all__ = [
17
17
  ]
18
18
  __author__ = """Lukas Sykora"""
19
19
  __email__ = 'lukas.sykora@vse.cz'
20
- __version__ = '2.0.0'
20
+ __version__ = '2.1.0'
@@ -908,6 +908,11 @@ class ActionRules:
908
908
  random_state: Optional[int] = None,
909
909
  analytic_type: str = "wald",
910
910
  bootstrap_type: str = "percentile",
911
+ *,
912
+ prior: Optional[str] = None,
913
+ simultaneous_m: Optional[int] = None,
914
+ resample_mode: str = "rows",
915
+ discovery_fraction: float = 0.5,
911
916
  ):
912
917
  r"""Compute confidence intervals for all fitted action rules.
913
918
 
@@ -999,7 +1004,15 @@ class ActionRules:
999
1004
  raise ValueError("n_bootstrap must be >= 1.")
1000
1005
  if n_mc < 1:
1001
1006
  raise ValueError("n_mc must be >= 1.")
1002
- valid_analytic_types = {"wald", "wilson", "newcombe_wilson", "auto"}
1007
+ valid_analytic_types = {
1008
+ "wald",
1009
+ "wald_rd",
1010
+ "wald_random",
1011
+ "wilson",
1012
+ "newcombe_wilson",
1013
+ "clopper_pearson",
1014
+ "auto",
1015
+ }
1003
1016
  if analytic_type not in valid_analytic_types:
1004
1017
  raise ValueError(f"Unknown analytic_type '{analytic_type}'. Choose from {valid_analytic_types}.")
1005
1018
  valid_bootstrap_types = {"percentile", "bca"}
@@ -1012,27 +1025,52 @@ class ActionRules:
1012
1025
  from .inference.base import categorize_rule, extract_rule_masks
1013
1026
 
1014
1027
  masks = extract_rule_masks(self.output)
1028
+ eval_data = data
1015
1029
 
1016
1030
  engine: Any
1017
- if method == "bootstrap":
1031
+ if method in ("bootstrap", "bca"):
1018
1032
  from .inference.bootstrap import BootstrapEngine
1019
1033
 
1020
- engine = BootstrapEngine(n_bootstrap, random_state, bootstrap_type=bootstrap_type)
1021
- elif method in ("analytic", "wald"):
1034
+ eff_btype = "bca" if method == "bca" else bootstrap_type
1035
+ engine = BootstrapEngine(
1036
+ n_bootstrap,
1037
+ random_state,
1038
+ bootstrap_type=eff_btype,
1039
+ resample_mode=resample_mode,
1040
+ )
1041
+ elif method in ("analytic", "wald", "wald_rd", "wald_random", "wilson", "newcombe_wilson", "clopper_pearson", "auto"):
1022
1042
  from .inference.analytic import AnalyticEngine
1023
1043
 
1024
- engine = AnalyticEngine(analytic_type=analytic_type)
1025
- elif method == "bayesian":
1044
+ eff_atype = analytic_type if method in ("analytic", "wald") else method
1045
+ engine = AnalyticEngine(analytic_type=eff_atype, simultaneous_m=simultaneous_m)
1046
+ elif method in ("bayesian", "jeffreys"):
1026
1047
  from .inference.bayesian import BayesianEngine
1027
1048
 
1028
- engine = BayesianEngine(n_mc, random_state=random_state)
1049
+ eff_prior = "jeffreys" if method == "jeffreys" else prior
1050
+ engine = BayesianEngine(n_mc, random_state=random_state, prior=eff_prior)
1051
+ elif method == "honest_split":
1052
+ if not (0.0 < discovery_fraction < 1.0):
1053
+ raise ValueError("discovery_fraction must be strictly between 0 and 1.")
1054
+ import numpy as _np
1055
+
1056
+ from .inference.analytic import AnalyticEngine
1057
+
1058
+ rng_split = _np.random.default_rng(random_state)
1059
+ n_rows = len(data)
1060
+ perm = rng_split.permutation(n_rows)
1061
+ n_disc = int(round(discovery_fraction * n_rows))
1062
+ est_idx = perm[n_disc:]
1063
+ eval_data = data.iloc[est_idx].reset_index(drop=True) if len(est_idx) > 0 else data
1064
+ eff_atype = "newcombe_wilson" if analytic_type == "wald" else analytic_type
1065
+ engine = AnalyticEngine(analytic_type=eff_atype, simultaneous_m=simultaneous_m)
1029
1066
  else:
1030
1067
  raise ValueError(
1031
- f"Unknown method '{method}'. Supported methods: 'bootstrap', 'analytic', 'wald', 'bayesian'."
1068
+ f"Unknown method '{method}'. Supported methods: 'bootstrap', 'bca', 'analytic', 'wald', "
1069
+ f"'wald_rd', 'newcombe_wilson', 'clopper_pearson', 'auto', 'bayesian', 'jeffreys', 'honest_split'."
1032
1070
  )
1033
1071
 
1034
1072
  results = engine.compute(
1035
- data=data,
1073
+ data=eval_data,
1036
1074
  rules=masks,
1037
1075
  confidence_level=confidence_level,
1038
1076
  intrinsic_utility_table=self._original_intrinsic_utility_table or None,
@@ -88,7 +88,7 @@ class AnalyticEngine(InferenceEngine):
88
88
  # Initialisation
89
89
  # ------------------------------------------------------------------
90
90
 
91
- def __init__(self, analytic_type: str = "wald") -> None:
91
+ def __init__(self, analytic_type: str = "wald", simultaneous_m: Optional[int] = None) -> None:
92
92
  r"""Initialise the engine with the chosen interval type.
93
93
 
94
94
  Parameters
@@ -97,31 +97,50 @@ class AnalyticEngine(InferenceEngine):
97
97
  Which closed-form interval to use. One of:
98
98
 
99
99
  ``'wald'``
100
- Standard Wald (normal approximation) interval. Default and
101
- backward-compatible choice.
100
+ Standard Wald (normal approximation) interval under fixed-
101
+ antecedent conditioning. Default and backward-compatible choice.
102
+ ``'wald_rd'`` or ``'wald_random'``
103
+ Random-design (RD) delta-method Wald interval that propagates
104
+ uncertainty in the antecedent prevalence :math:`\hat{q}_u = n_u / N`
105
+ alongside the conditional proportions :math:`\hat{p}_u, \hat{p}_d`.
102
106
  ``'newcombe_wilson'`` (preferred) or ``'wilson'`` (alias)
103
107
  Newcombe-Wilson interval for the unscaled rule contrast
104
- :math:`\\delta = p_d + p_u - 1` (Newcombe, 1998), built by
108
+ :math:`\delta = p_d + p_u - 1` (Newcombe, 1998), built by
105
109
  combining two single-proportion Wilson score intervals.
106
110
  More accurate for small samples or proportions near 0 or 1.
107
111
  ``'wilson'`` is retained as a backward-compatible alias and
108
112
  resolves to the same Newcombe-Wilson construction.
113
+ ``'clopper_pearson'``
114
+ Exact Clopper-Pearson single-proportion intervals combined via
115
+ Newcombe's square-and-add root.
109
116
  ``'auto'``
110
117
  Selects the Newcombe-Wilson branch when either side has
111
118
  ``n < 40`` or ``p < 0.05`` / ``p > 0.95``; falls back to
112
119
  Wald otherwise (Agresti & Coull, 1998).
120
+ simultaneous_m : int, optional
121
+ When provided (``>= 1``), applies a Bonferroni simultaneous
122
+ multiplicity correction over *simultaneous_m* candidate rules by
123
+ setting :math:`\alpha_{\mathrm{eff}} = (1 - \text{confidence\_level}) / m`.
113
124
 
114
125
  Raises
115
126
  ------
116
127
  ValueError
117
- If *analytic_type* is not one of the recognised values.
128
+ If *analytic_type* is not one of the recognised values or
129
+ *simultaneous_m* is less than 1.
118
130
  """
119
- valid = {"wald", "wilson", "newcombe_wilson", "auto"}
131
+ valid = {"wald", "wald_rd", "wald_random", "wilson", "newcombe_wilson", "clopper_pearson", "auto"}
120
132
  if analytic_type not in valid:
121
133
  raise ValueError(f"Unknown analytic_type '{analytic_type}'. Choose from {valid}.")
122
- # Canonicalise: 'wilson' is a backward-compatible alias for
123
- # 'newcombe_wilson'. Both invoke the same Newcombe-Wilson code path.
124
- self.analytic_type = "newcombe_wilson" if analytic_type == "wilson" else analytic_type
134
+ if simultaneous_m is not None and simultaneous_m < 1:
135
+ raise ValueError(f"simultaneous_m must be >= 1, got {simultaneous_m}.")
136
+ # Canonicalise aliases
137
+ if analytic_type == "wilson":
138
+ self.analytic_type = "newcombe_wilson"
139
+ elif analytic_type == "wald_random":
140
+ self.analytic_type = "wald_rd"
141
+ else:
142
+ self.analytic_type = analytic_type
143
+ self.simultaneous_m = simultaneous_m
125
144
 
126
145
  # ------------------------------------------------------------------
127
146
  # Private helpers
@@ -206,6 +225,38 @@ class AnalyticEngine(InferenceEngine):
206
225
  upper = delta_hat + sqrt((u_d - p_d) ** 2 + (u_u - p_u) ** 2)
207
226
  return delta_hat, lower, upper
208
227
 
228
+ @staticmethod
229
+ def _clopper_pearson_bounds(x: float, n: float, alpha: float) -> Tuple[float, float, float]:
230
+ """Single-proportion exact Clopper-Pearson interval (Clopper & Pearson, 1934).
231
+
232
+ Parameters
233
+ ----------
234
+ x : float
235
+ Number of successes.
236
+ n : float
237
+ Number of trials.
238
+ alpha : float
239
+ Two-sided significance level ``1 - confidence_level``.
240
+
241
+ Returns
242
+ -------
243
+ p_hat : float
244
+ Maximum-likelihood proportion ``x / n``.
245
+ lower : float
246
+ Exact Clopper-Pearson lower bound.
247
+ upper : float
248
+ Exact Clopper-Pearson upper bound.
249
+ """
250
+ if n <= 0:
251
+ nan = float('nan')
252
+ return nan, nan, nan
253
+ from scipy.stats import beta
254
+
255
+ p_hat = x / n
256
+ lower = 0.0 if x <= 0 else float(beta.ppf(alpha / 2.0, x, n - x + 1.0))
257
+ upper = 1.0 if x >= n else float(beta.ppf(1.0 - alpha / 2.0, x + 1.0, n - x))
258
+ return p_hat, lower, upper
259
+
209
260
  # ------------------------------------------------------------------
210
261
  # Public API
211
262
  # ------------------------------------------------------------------
@@ -265,6 +316,8 @@ class AnalyticEngine(InferenceEngine):
265
316
  compute_gain = (intrinsic_utility_table is not None) or (transition_utility_table is not None)
266
317
 
267
318
  alpha = 1.0 - confidence_level
319
+ if self.simultaneous_m is not None and self.simultaneous_m > 1:
320
+ alpha = alpha / float(self.simultaneous_m)
268
321
  z = norm.ppf(1.0 - alpha / 2.0)
269
322
 
270
323
  results: List[ConfidenceIntervalResult] = []
@@ -313,7 +366,17 @@ class AnalyticEngine(InferenceEngine):
313
366
  and (n_u_ante < 40 or n_d_ante < 40 or p_u < 0.05 or p_u > 0.95 or p_d < 0.05 or p_d > 0.95)
314
367
  )
315
368
 
316
- if use_wilson:
369
+ var_p_u = p_u * (1.0 - p_u) / n_u_ante
370
+ var_p_d = p_d * (1.0 - p_d) / n_d_ante
371
+ var_delta = var_p_u + var_p_d
372
+
373
+ if self.analytic_type == "clopper_pearson":
374
+ _, l_u, u_u = self._clopper_pearson_bounds(n_u_match, n_u_ante, alpha)
375
+ _, l_d, u_d = self._clopper_pearson_bounds(n_d_match, n_d_ante, alpha)
376
+ _, delta_lower, delta_upper = self._newcombe_delta_interval(
377
+ p_u, l_u, u_u, p_d, l_d, u_d
378
+ )
379
+ elif use_wilson:
317
380
  # Newcombe (1998) combination of single-proportion Wilson intervals.
318
381
  _, l_u, u_u = self._wilson_bounds(n_u_match, n_u_ante, z)
319
382
  _, l_d, u_d = self._wilson_bounds(n_d_match, n_d_ante, z)
@@ -322,21 +385,25 @@ class AnalyticEngine(InferenceEngine):
322
385
  )
323
386
  else:
324
387
  # Wald delta-method interval for delta = p_d + p_u - 1.
325
- var_p_u = p_u * (1.0 - p_u) / n_u_ante
326
- var_p_d = p_d * (1.0 - p_d) / n_d_ante
327
- var_delta = var_p_u + var_p_d
328
388
  delta_se = sqrt(var_delta)
329
389
  delta_lower = delta_hat - z * delta_se
330
390
  delta_upper = delta_hat + z * delta_se
331
391
 
332
392
  # Transform delta -> population-scaled uplift.
333
393
  uplift_point = delta_hat * scale
334
- uplift_lower = delta_lower * scale
335
- uplift_upper = delta_upper * scale
336
- # Average half-width divided by z gives an SE-equivalent for an
337
- # asymmetric interval; reduces to the exact Wald SE in the Wald
338
- # branch.
339
- se_uplift = (uplift_upper - uplift_lower) / (2.0 * z) if z > 0 else 0.0
394
+ if self.analytic_type == "wald_rd":
395
+ # Random-design delta-method variance: accounts for random antecedent prevalence q_u = n_u / N.
396
+ var_uplift = (scale ** 2) * var_delta + (delta_hat ** 2) * (scale * (1.0 - scale) / n_total)
397
+ se_uplift = sqrt(var_uplift)
398
+ uplift_lower = uplift_point - z * se_uplift
399
+ uplift_upper = uplift_point + z * se_uplift
400
+ else:
401
+ uplift_lower = delta_lower * scale
402
+ uplift_upper = delta_upper * scale
403
+ # Average half-width divided by z gives an SE-equivalent for an
404
+ # asymmetric interval; reduces to the exact Wald SE in the Wald
405
+ # branch.
406
+ se_uplift = (uplift_upper - uplift_lower) / (2.0 * z) if z > 0 else 0.0
340
407
 
341
408
  support = n_u_ante
342
409
  confidence = p_u
@@ -7,6 +7,7 @@ functions consumed by all CI engines (analytic, bootstrap, Bayesian).
7
7
  from abc import ABC, abstractmethod
8
8
  from dataclasses import dataclass, field
9
9
  from enum import Enum
10
+ import math
10
11
  from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
11
12
 
12
13
  import numpy as np
@@ -32,11 +33,14 @@ class RuleCategory(Enum):
32
33
  The entire CI is below the threshold — rule should be discarded.
33
34
  UNCERTAIN : str
34
35
  The CI straddles the threshold — insufficient evidence to decide.
36
+ UNSUPPORTED : str
37
+ Zero antecedent support or undefined CI bounds (NaN).
35
38
  """
36
39
 
37
40
  ACCEPT = 'accept'
38
41
  REJECT = 'reject'
39
42
  UNCERTAIN = 'uncertain'
43
+ UNSUPPORTED = 'unsupported'
40
44
 
41
45
 
42
46
  # ---------------------------------------------------------------------------
@@ -147,6 +151,27 @@ class ConfidenceIntervalResult:
147
151
  samples_uplift: Optional[np.ndarray] = field(default=None, repr=False)
148
152
  samples_gain: Optional[np.ndarray] = field(default=None, repr=False)
149
153
 
154
+ @property
155
+ def decision_status(self) -> str:
156
+ """Return a four-way decision status string.
157
+
158
+ Returns
159
+ -------
160
+ str
161
+ One of ``'accept'``, ``'reject'``, ``'uncertain'``, or
162
+ ``'unsupported'`` (when ``support == 0`` or CI bounds are ``NaN``).
163
+ """
164
+ if (
165
+ self.support == 0
166
+ or math.isnan(self.uplift_ci_lower)
167
+ or math.isnan(self.uplift_ci_upper)
168
+ or self.category == RuleCategory.UNSUPPORTED
169
+ ):
170
+ return RuleCategory.UNSUPPORTED.value
171
+ if self.category is not None:
172
+ return self.category.value
173
+ return RuleCategory.UNCERTAIN.value
174
+
150
175
  def to_dict(self, include_samples: bool = False) -> dict:
151
176
  """Return a plain dict representation of this result.
152
177
 
@@ -601,10 +626,13 @@ def categorize_rule(ci_lower: float, ci_upper: float, threshold: float) -> RuleC
601
626
  Returns
602
627
  -------
603
628
  RuleCategory
629
+ - :attr:`RuleCategory.UNSUPPORTED` when either CI bound is ``NaN``
604
630
  - :attr:`RuleCategory.ACCEPT` when ``ci_lower >= threshold``
605
631
  - :attr:`RuleCategory.REJECT` when ``ci_upper < threshold``
606
632
  - :attr:`RuleCategory.UNCERTAIN` otherwise
607
633
  """
634
+ if math.isnan(ci_lower) or math.isnan(ci_upper):
635
+ return RuleCategory.UNSUPPORTED
608
636
  if ci_lower >= threshold:
609
637
  return RuleCategory.ACCEPT
610
638
  if ci_upper < threshold:
@@ -74,11 +74,26 @@ class BayesianEngine(InferenceEngine):
74
74
  prior_alpha: float = 1.0,
75
75
  prior_beta: float = 1.0,
76
76
  random_state: Optional[int] = None,
77
+ *,
78
+ prior: Optional[str] = None,
77
79
  ):
80
+ if prior is not None:
81
+ prior_norm = prior.lower()
82
+ if prior_norm in ("uniform", "laplace"):
83
+ prior_alpha, prior_beta = 1.0, 1.0
84
+ elif prior_norm == "jeffreys":
85
+ prior_alpha, prior_beta = 0.5, 0.5
86
+ elif prior_norm == "haldane":
87
+ prior_alpha, prior_beta = 0.01, 0.01
88
+ else:
89
+ raise ValueError(
90
+ f"Unknown prior '{prior}'. Choose from {{'uniform', 'laplace', 'jeffreys', 'haldane'}}."
91
+ )
78
92
  self.n_mc = n_mc
79
93
  self.prior_alpha = prior_alpha
80
94
  self.prior_beta = prior_beta
81
95
  self.random_state = random_state
96
+ self.prior = prior
82
97
 
83
98
  # ------------------------------------------------------------------
84
99
  # Public API
@@ -134,13 +134,18 @@ class BootstrapEngine(InferenceEngine):
134
134
  random_state: Optional[int] = None,
135
135
  *,
136
136
  bootstrap_type: str = "percentile",
137
+ resample_mode: str = "rows",
137
138
  ):
138
139
  valid = {"percentile", "bca"}
139
140
  if bootstrap_type not in valid:
140
141
  raise ValueError(f"Unknown bootstrap_type '{bootstrap_type}'. Choose from {valid}.")
142
+ valid_modes = {"rows", "multinomial"}
143
+ if resample_mode not in valid_modes:
144
+ raise ValueError(f"Unknown resample_mode '{resample_mode}'. Choose from {valid_modes}.")
141
145
  self.n_bootstrap = n_bootstrap
142
146
  self.random_state = random_state
143
147
  self.bootstrap_type = bootstrap_type
148
+ self.resample_mode = resample_mode
144
149
 
145
150
  # ------------------------------------------------------------------
146
151
  # Static helpers
@@ -433,41 +438,81 @@ class BootstrapEngine(InferenceEngine):
433
438
  gain_samples[:] = np.nan
434
439
 
435
440
  n_undefined = 0
436
- for b in range(self.n_bootstrap):
437
- # Draw a bootstrap resample (with replacement) using integer
438
- # index sampling, which is compatible with default_rng.
439
- indices = rng.integers(0, n, size=n)
440
-
441
- # Fancy-index the pre-computed boolean masks instead of
442
- # slicing the DataFrame — avoids Python-level iteration and
443
- # repeated string comparisons.
444
- n_u_ante = int(u_ante[indices].sum())
445
- n_u_match = int(u_match[indices].sum())
446
- n_d_ante = int(d_ante[indices].sum())
447
- n_d_match = int(d_match[indices].sum())
448
- n_total = n
449
-
450
- # Skip resamples where antecedent support is zero — uplift is
451
- # undefined for these draws. Track the fraction so the caller
452
- # can diagnose unstable percentile intervals on low-support rules.
453
- if n_u_ante == 0 or n_d_ante == 0:
454
- n_undefined += 1
455
- continue
456
-
457
- uplift_samples[b] = compute_uplift_from_counts(n_u_ante, n_u_match, n_d_ante, n_d_match, n_total)
458
-
459
- if gain_samples is not None and column_values is not None:
460
- conf_u = n_u_match / n_u_ante
461
- conf_d = n_d_match / n_d_ante
462
- gain_samples[b] = compute_realistic_gain(
463
- rule,
464
- conf_u,
465
- conf_d,
466
- intrinsic_utility_table,
467
- transition_utility_table,
468
- column_values,
441
+ if self.resample_mode == "multinomial" and n > 0:
442
+ c_u_match = int(u_match.sum())
443
+ c_u_nonmatch = max(int(u_ante.sum()) - c_u_match, 0)
444
+ c_d_match = int(d_match.sum())
445
+ c_d_nonmatch = max(int(d_ante.sum()) - c_d_match, 0)
446
+ c_other = max(n - (c_u_match + c_u_nonmatch + c_d_match + c_d_nonmatch), 0)
447
+ probs = np.array(
448
+ [c_u_match, c_u_nonmatch, c_d_match, c_d_nonmatch, c_other],
449
+ dtype=float,
450
+ )
451
+ total_p = probs.sum()
452
+ if total_p > 0:
453
+ probs /= total_p
454
+ draws = rng.multinomial(n, probs, size=self.n_bootstrap)
455
+ nu_arr = draws[:, 0] + draws[:, 1]
456
+ num_arr = draws[:, 0]
457
+ nd_arr = draws[:, 2] + draws[:, 3]
458
+ ndm_arr = draws[:, 2]
459
+ valid_mask = (nu_arr > 0) & (nd_arr > 0)
460
+ n_undefined = int((~valid_mask).sum())
461
+ if np.any(valid_mask):
462
+ conf_u_arr = num_arr[valid_mask] / nu_arr[valid_mask]
463
+ conf_d_arr = ndm_arr[valid_mask] / nd_arr[valid_mask]
464
+ d_arr = conf_d_arr + conf_u_arr - 1.0
465
+ uplift_samples[valid_mask] = d_arr * (nu_arr[valid_mask] / float(n))
466
+ if gain_samples is not None and column_values is not None:
467
+ for idx_b in np.where(valid_mask)[0]:
468
+ gain_samples[idx_b] = compute_realistic_gain(
469
+ rule,
470
+ float(num_arr[idx_b] / nu_arr[idx_b]),
471
+ float(ndm_arr[idx_b] / nd_arr[idx_b]),
472
+ intrinsic_utility_table,
473
+ transition_utility_table,
474
+ column_values,
475
+ )
476
+ else:
477
+ n_undefined = self.n_bootstrap
478
+ else:
479
+ for b in range(self.n_bootstrap):
480
+ # Draw a bootstrap resample (with replacement) using integer
481
+ # index sampling, which is compatible with default_rng.
482
+ indices = rng.integers(0, n, size=n)
483
+
484
+ # Fancy-index the pre-computed boolean masks instead of
485
+ # slicing the DataFrame — avoids Python-level iteration and
486
+ # repeated string comparisons.
487
+ n_u_ante = int(u_ante[indices].sum())
488
+ n_u_match = int(u_match[indices].sum())
489
+ n_d_ante = int(d_ante[indices].sum())
490
+ n_d_match = int(d_match[indices].sum())
491
+ n_total = n
492
+
493
+ # Skip resamples where antecedent support is zero — uplift is
494
+ # undefined for these draws. Track the fraction so the caller
495
+ # can diagnose unstable percentile intervals on low-support rules.
496
+ if n_u_ante == 0 or n_d_ante == 0:
497
+ n_undefined += 1
498
+ continue
499
+
500
+ uplift_samples[b] = compute_uplift_from_counts(
501
+ n_u_ante, n_u_match, n_d_ante, n_d_match, n_total
469
502
  )
470
503
 
504
+ if gain_samples is not None and column_values is not None:
505
+ conf_u = n_u_match / n_u_ante
506
+ conf_d = n_d_match / n_d_ante
507
+ gain_samples[b] = compute_realistic_gain(
508
+ rule,
509
+ conf_u,
510
+ conf_d,
511
+ intrinsic_utility_table,
512
+ transition_utility_table,
513
+ column_values,
514
+ )
515
+
471
516
  # Compute full-dataset counts from pre-computed masks.
472
517
  # These are needed by both percentile and BCa branches.
473
518
  n_u_ante_full = int(u_ante.sum())
@@ -183,6 +183,13 @@ class Rules:
183
183
  for attribute_prefix, rules in self.classification_rules.items():
184
184
  for desired_rule in rules['desired']:
185
185
  for undesired_rule in rules['undesired']:
186
+ # Skip degenerate self-transitions when a real flexible prefix is present
187
+ if (
188
+ len(attribute_prefix) > 0
189
+ and undesired_rule.get('itemset') is not None
190
+ and undesired_rule.get('itemset') == desired_rule.get('itemset')
191
+ ):
192
+ continue
186
193
  # Uplift
187
194
  uplift = self.calculate_uplift(
188
195
  undesired_rule['support'],
@@ -299,6 +306,8 @@ class Rules:
299
306
  Uplift measures the increase in the probability of achieving the desired state when applying the action rule
300
307
  compared to not applying it. It is used to assess the effectiveness of the rule.
301
308
  """
309
+ if undesired_confidence == 0 or self.count_transactions == 0:
310
+ return 0.0
302
311
  return (
303
312
  (desired_confidence - (1 - undesired_confidence)) * (undesired_support / undesired_confidence)
304
313
  ) / self.count_transactions
@@ -821,3 +821,39 @@ class TestAutoMode:
821
821
 
822
822
  assert auto.uplift_point == pytest.approx(wald.uplift_point, rel=1e-12)
823
823
  assert auto.uplift_se == pytest.approx(wald.uplift_se, rel=1e-12)
824
+
825
+
826
+ class TestRandomDesignAndClopperPearson:
827
+ """Tests for v2.1.0 random-design Wald ('wald_rd'), 'clopper_pearson', and 'simultaneous_m'."""
828
+
829
+ def test_wald_rd_wider_than_fixed_wald(self):
830
+ """Random-design Wald propagates prevalence uncertainty and is at least as wide as fixed Wald."""
831
+ data = _make_data(n=200)
832
+ extra = pd.DataFrame({'age': ['0'] * 10, 'class': ['0'] * 10, 'target': ['1'] * 10})
833
+ mixed = pd.concat([data, extra], ignore_index=True)
834
+
835
+ w_fixed = AnalyticEngine(analytic_type="wald").compute(mixed, [_make_rule()])[0]
836
+ w_rd = AnalyticEngine(analytic_type="wald_rd").compute(mixed, [_make_rule()])[0]
837
+ assert w_rd.uplift_point == pytest.approx(w_fixed.uplift_point, rel=1e-12)
838
+ assert w_rd.uplift_se >= w_fixed.uplift_se
839
+
840
+ def test_clopper_pearson_conservative(self):
841
+ """Clopper-Pearson combined via Newcombe produces valid conservative bounds."""
842
+ data = _make_data(n=100)
843
+ extra = pd.DataFrame({'age': ['0'] * 5, 'class': ['0'] * 5, 'target': ['1'] * 5})
844
+ mixed = pd.concat([data, extra], ignore_index=True)
845
+
846
+ cp = AnalyticEngine(analytic_type="clopper_pearson").compute(mixed, [_make_rule()])[0]
847
+ nw = AnalyticEngine(analytic_type="newcombe_wilson").compute(mixed, [_make_rule()])[0]
848
+ assert cp.uplift_point == pytest.approx(nw.uplift_point, rel=1e-12)
849
+ assert (cp.uplift_ci_upper - cp.uplift_ci_lower) >= (nw.uplift_ci_upper - nw.uplift_ci_lower)
850
+
851
+ def test_simultaneous_m_widens_interval(self):
852
+ """Bonferroni multiplicity simultaneous_m > 1 widens the confidence interval."""
853
+ data = _make_data(n=200)
854
+ extra = pd.DataFrame({'age': ['0'] * 10, 'class': ['0'] * 10, 'target': ['1'] * 10})
855
+ mixed = pd.concat([data, extra], ignore_index=True)
856
+
857
+ r1 = AnalyticEngine(analytic_type="newcombe_wilson", simultaneous_m=1).compute(mixed, [_make_rule()])[0]
858
+ r10 = AnalyticEngine(analytic_type="newcombe_wilson", simultaneous_m=10).compute(mixed, [_make_rule()])[0]
859
+ assert (r10.uplift_ci_upper - r10.uplift_ci_lower) > (r1.uplift_ci_upper - r1.uplift_ci_lower)
@@ -171,3 +171,16 @@ class TestCategorizeRule:
171
171
  """Verify a CI lower bound equal to the threshold yields ACCEPT."""
172
172
  # Boundary case: ci_lower exactly at threshold counts as ACCEPT.
173
173
  assert categorize_rule(0.0, 0.2, threshold=0.0) is RuleCategory.ACCEPT
174
+
175
+ def test_unsupported_when_bounds_nan(self):
176
+ """Verify NaN CI bounds yield RuleCategory.UNSUPPORTED."""
177
+ assert categorize_rule(float('nan'), float('nan'), threshold=0.0) is RuleCategory.UNSUPPORTED
178
+ assert categorize_rule(float('nan'), 0.2, threshold=0.0) is RuleCategory.UNSUPPORTED
179
+
180
+ def test_decision_status_property(self):
181
+ """Verify ConfidenceIntervalResult.decision_status returns 4-way status."""
182
+ r_acc = _make_result(0, category=RuleCategory.ACCEPT)
183
+ assert r_acc.decision_status == 'accept'
184
+ r_unsup = _make_result(1, category=None)
185
+ r_unsup.support = 0
186
+ assert r_unsup.decision_status == 'unsupported'
@@ -451,3 +451,14 @@ class TestMultipleRules:
451
451
  result = BayesianEngine(n_mc=5000, random_state=0).compute(data, [_make_rule()])[0]
452
452
  # The synthetic data gives perfect uplift; the posterior CI should be fully > 0.
453
453
  assert result.category == RuleCategory.ACCEPT
454
+
455
+ def test_named_priors_jeffreys_and_uniform(self):
456
+ """Named priors 'jeffreys', 'uniform', and 'haldane' set the expected Beta hyperparameters."""
457
+ eng_j = BayesianEngine(n_mc=100, random_state=0, prior="jeffreys")
458
+ assert eng_j.prior_alpha == 0.5 and eng_j.prior_beta == 0.5
459
+ eng_u = BayesianEngine(n_mc=100, random_state=0, prior="uniform")
460
+ assert eng_u.prior_alpha == 1.0 and eng_u.prior_beta == 1.0
461
+ eng_h = BayesianEngine(n_mc=100, random_state=0, prior="haldane")
462
+ assert eng_h.prior_alpha == 0.01 and eng_h.prior_beta == 0.01
463
+ with pytest.raises(ValueError, match="Unknown prior"):
464
+ BayesianEngine(prior="invalid_prior")
@@ -643,3 +643,16 @@ class TestDegenerateResampleReporting:
643
643
  # miss them entirely.
644
644
  assert result.undefined_bootstrap_fraction is not None
645
645
  assert result.undefined_bootstrap_fraction > 0.01
646
+
647
+ def test_multinomial_resample_mode_matches_rows_distribution(self):
648
+ """Vectorised 5-cell multinomial resampling agrees closely with row resampling."""
649
+ data = _make_data(n=400)
650
+ r_rows = BootstrapEngine(n_bootstrap=500, random_state=42, resample_mode="rows").compute(
651
+ data, [_make_rule()]
652
+ )[0]
653
+ r_mult = BootstrapEngine(n_bootstrap=500, random_state=42, resample_mode="multinomial").compute(
654
+ data, [_make_rule()]
655
+ )[0]
656
+ assert r_mult.uplift_point == pytest.approx(r_rows.uplift_point, abs=0.03)
657
+ assert r_mult.uplift_ci_lower == pytest.approx(r_rows.uplift_ci_lower, abs=0.05)
658
+ assert r_mult.uplift_ci_upper == pytest.approx(r_rows.uplift_ci_upper, abs=0.05)
@@ -167,3 +167,24 @@ def test_compute_rule_utility(rules):
167
167
  assert abs(result[0] - expected[0]) < tol, f"max_rule_gain: expected {expected[0]}, got {result[0]}"
168
168
  assert abs(result[1] - expected[1]) < tol, f"realistic_rule_gain: expected {expected[1]}, got {result[1]}"
169
169
  assert abs(result[2] - expected[2]) < tol, f"realistic_rule_gain_dataset: expected {expected[2]}, got {result[2]}"
170
+
171
+
172
+ def test_calculate_uplift_zero_confidence_and_zero_transactions(rules):
173
+ """calculate_uplift returns 0.0 instead of raising ZeroDivisionError when confidence or transactions are 0."""
174
+ assert rules.calculate_uplift(0, 0.0, 0.8) == 0.0
175
+ rules.count_transactions = 0
176
+ assert rules.calculate_uplift(10, 0.5, 0.8) == 0.0
177
+
178
+
179
+ def test_generate_action_rules_skips_identical_itemsets_with_prefix(rules):
180
+ """generate_action_rules skips self-paired identical itemsets when attribute_prefix is non-empty."""
181
+ rules.add_classification_rules(
182
+ ('flex_attr',),
183
+ (0,),
184
+ [{'item': 1, 'support': 10, 'confidence': 0.4}, {'item': 2, 'support': 12, 'confidence': 0.7}],
185
+ [{'item': 1, 'support': 15, 'confidence': 0.6}],
186
+ )
187
+ rules.generate_action_rules()
188
+ assert len(rules.action_rules) == 1
189
+ assert rules.action_rules[0]['undesired']['itemset'] == (0, 2)
190
+ assert rules.action_rules[0]['desired']['itemset'] == (0, 1)
@@ -675,3 +675,48 @@ def test_fit_uses_bfs_candidate_expansion(action_rules, monkeypatch):
675
675
  )
676
676
 
677
677
  assert visited_prefixes == [tuple(), ('a',), ('b',), ('a', 'a1')]
678
+
679
+
680
+ def test_mining_below_half_confidence():
681
+ """
682
+ Test that confidence thresholds below 0.5 are accepted and honoured.
683
+
684
+ The package does not enforce a lower bound on
685
+ ``min_undesired_confidence`` / ``min_desired_confidence``; mining must
686
+ therefore also return rules whose side confidences lie below 0.5.
687
+
688
+ Asserts
689
+ -------
690
+ Asserts that rules are mined with thresholds of 0.3 and that at least
691
+ one discovered rule has an undesired-side confidence below 0.5.
692
+ """
693
+ import json
694
+
695
+ df = pd.DataFrame(
696
+ {
697
+ 'A': ['x'] * 10,
698
+ 'F': ['a'] * 5 + ['b'] * 5,
699
+ 'T': ['u', 'u', 'd', 'd', 'd', 'd', 'd', 'd', 'd', 'u'],
700
+ }
701
+ )
702
+ ar = ActionRules(
703
+ min_stable_attributes=1,
704
+ min_flexible_attributes=1,
705
+ min_undesired_support=1,
706
+ min_undesired_confidence=0.3,
707
+ min_desired_support=1,
708
+ min_desired_confidence=0.3,
709
+ verbose=False,
710
+ )
711
+ ar.fit(
712
+ data=df,
713
+ stable_attributes=['A'],
714
+ flexible_attributes=['F'],
715
+ target='T',
716
+ target_undesired_state='u',
717
+ target_desired_state='d',
718
+ )
719
+ exported = json.loads(ar.get_rules().get_export_notation())
720
+ assert len(exported) > 0
721
+ undesired_confidences = [float(r['confidence of undesired part']) for r in exported]
722
+ assert any(c < 0.5 for c in undesired_confidences)
@@ -1150,3 +1150,23 @@ class TestCLI:
1150
1150
  finally:
1151
1151
  os.unlink(csv_path)
1152
1152
  os.unlink(json_path)
1153
+
1154
+
1155
+ class TestV210Extensions:
1156
+ """Integration tests for v2.1.0 confidence_intervals methods and options."""
1157
+
1158
+ def test_wald_rd_and_clopper_pearson_and_jeffreys_and_honest_split(self, fitted_ar):
1159
+ """Verify new method aliases ('wald_rd', 'clopper_pearson', 'jeffreys', 'honest_split') run cleanly."""
1160
+ ar, data = fitted_ar
1161
+ res_rd = ar.confidence_intervals(data, method="wald_rd")
1162
+ assert len(res_rd) == len(ar.output.action_rules)
1163
+ res_cp = ar.confidence_intervals(data, method="clopper_pearson", simultaneous_m=len(res_rd))
1164
+ assert len(res_cp) == len(ar.output.action_rules)
1165
+ res_j = ar.confidence_intervals(data, method="jeffreys", n_mc=200, random_state=42)
1166
+ assert len(res_j) == len(ar.output.action_rules)
1167
+ res_hs = ar.confidence_intervals(data, method="honest_split", discovery_fraction=0.5, random_state=42)
1168
+ assert len(res_hs) == len(ar.output.action_rules)
1169
+ res_mult = ar.confidence_intervals(
1170
+ data, method="bootstrap", n_bootstrap=50, random_state=42, resample_mode="multinomial"
1171
+ )
1172
+ assert len(res_mult) == len(ar.output.action_rules)
File without changes