AutoCarver 7.3.2__tar.gz → 7.3.4__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 (84) hide show
  1. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/__init__.py +4 -1
  2. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/carvers/__init__.py +2 -1
  3. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/carvers/multiclass_carver.py +3 -0
  4. autocarver-7.3.4/AutoCarver/carvers/ordinal_carver.py +101 -0
  5. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/carvers/utils/base_carver.py +24 -1
  6. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/combinations/__init__.py +10 -0
  7. autocarver-7.3.4/AutoCarver/combinations/ordinal/__init__.py +15 -0
  8. autocarver-7.3.4/AutoCarver/combinations/ordinal/ordinal_combination_evaluators.py +424 -0
  9. autocarver-7.3.4/AutoCarver/combinations/ordinal/ordinal_target_rates.py +73 -0
  10. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/combinations/utils/combination_evaluator.py +1 -0
  11. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/combinations/utils/combinations.py +25 -1
  12. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/features/__init__.py +10 -0
  13. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/features/features.py +96 -33
  14. autocarver-7.3.4/AutoCarver/features/llm_qualifier.py +185 -0
  15. autocarver-7.3.4/AutoCarver/mcp/__init__.py +63 -0
  16. autocarver-7.3.4/AutoCarver/mcp/__main__.py +6 -0
  17. autocarver-7.3.4/AutoCarver/mcp/inspection.py +168 -0
  18. autocarver-7.3.4/AutoCarver/mcp/server.py +111 -0
  19. autocarver-7.3.4/AutoCarver/mcp/session.py +230 -0
  20. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/selectors/__init__.py +2 -0
  21. autocarver-7.3.4/AutoCarver/selectors/ordinal_selector.py +23 -0
  22. {autocarver-7.3.2 → autocarver-7.3.4}/PKG-INFO +13 -1
  23. {autocarver-7.3.2 → autocarver-7.3.4}/README.md +9 -0
  24. {autocarver-7.3.2 → autocarver-7.3.4}/pyproject.toml +8 -1
  25. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/carvers/binary_carver.py +0 -0
  26. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/carvers/continuous_carver.py +0 -0
  27. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/carvers/utils/__init__.py +0 -0
  28. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/carvers/utils/pretty_print.py +0 -0
  29. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/combinations/binary/__init__.py +0 -0
  30. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/combinations/binary/binary_combination_evaluators.py +0 -0
  31. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/combinations/binary/binary_target_rates.py +0 -0
  32. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/combinations/continuous/__init__.py +0 -0
  33. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/combinations/continuous/continuous_combination_evaluators.py +0 -0
  34. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/combinations/continuous/continuous_target_rates.py +0 -0
  35. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/combinations/utils/__init__.py +0 -0
  36. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/combinations/utils/target_rate.py +0 -0
  37. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/combinations/utils/testing.py +0 -0
  38. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/config.py +0 -0
  39. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/discretizers/__init__.py +0 -0
  40. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/discretizers/discretizer.py +0 -0
  41. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/discretizers/qualitatives/__init__.py +0 -0
  42. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/discretizers/qualitatives/categorical_discretizer.py +0 -0
  43. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/discretizers/qualitatives/nested_discretizer.py +0 -0
  44. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/discretizers/qualitatives/ordinal_discretizer.py +0 -0
  45. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/discretizers/qualitatives/qualitative_discretizer.py +0 -0
  46. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/discretizers/quantitatives/__init__.py +0 -0
  47. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/discretizers/quantitatives/continuous_discretizer.py +0 -0
  48. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/discretizers/quantitatives/quantitative_discretizer.py +0 -0
  49. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/discretizers/utils/__init__.py +0 -0
  50. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/discretizers/utils/base_discretizer.py +0 -0
  51. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/discretizers/utils/frequency_ci.py +0 -0
  52. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/discretizers/utils/multiprocessing.py +0 -0
  53. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/discretizers/utils/type_discretizers.py +0 -0
  54. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/features/qualitatives/__init__.py +0 -0
  55. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/features/qualitatives/categorical_feature.py +0 -0
  56. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/features/qualitatives/nested_feature.py +0 -0
  57. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/features/qualitatives/ordinal_feature.py +0 -0
  58. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/features/qualitatives/qualitative_feature.py +0 -0
  59. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/features/quantitatives/__init__.py +0 -0
  60. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/features/quantitatives/datetime_feature.py +0 -0
  61. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/features/quantitatives/numerical_feature.py +0 -0
  62. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/features/quantitatives/quantitative_feature.py +0 -0
  63. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/features/utils/__init__.py +0 -0
  64. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/features/utils/base_feature.py +0 -0
  65. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/features/utils/grouped_list.py +0 -0
  66. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/features/utils/serialization.py +0 -0
  67. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/selectors/classification_selector.py +0 -0
  68. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/selectors/filters/__init__.py +0 -0
  69. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/selectors/filters/base_filters.py +0 -0
  70. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/selectors/filters/qualitative_filters.py +0 -0
  71. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/selectors/filters/quantitative_filters.py +0 -0
  72. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/selectors/measures/__init__.py +0 -0
  73. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/selectors/measures/_vectorized.py +0 -0
  74. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/selectors/measures/base_measures.py +0 -0
  75. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/selectors/measures/qualitative_measures.py +0 -0
  76. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/selectors/measures/quantitative_measures.py +0 -0
  77. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/selectors/regression_selector.py +0 -0
  78. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/selectors/utils/__init__.py +0 -0
  79. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/selectors/utils/base_selector.py +0 -0
  80. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/selectors/utils/pretty_print.py +0 -0
  81. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/utils/__init__.py +0 -0
  82. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/utils/attributes.py +0 -0
  83. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/utils/dependencies.py +0 -0
  84. {autocarver-7.3.2 → autocarver-7.3.4}/AutoCarver/utils/extend_docstring.py +0 -0
@@ -3,14 +3,17 @@
3
3
  from AutoCarver.carvers.binary_carver import BinaryCarver
4
4
  from AutoCarver.carvers.continuous_carver import ContinuousCarver
5
5
  from AutoCarver.carvers.multiclass_carver import MulticlassCarver
6
+ from AutoCarver.carvers.ordinal_carver import OrdinalCarver
6
7
  from AutoCarver.features import Features
7
- from AutoCarver.selectors import ClassificationSelector, RegressionSelector
8
+ from AutoCarver.selectors import ClassificationSelector, OrdinalSelector, RegressionSelector
8
9
 
9
10
  __all__ = [
10
11
  "BinaryCarver",
11
12
  "ContinuousCarver",
12
13
  "Features",
13
14
  "MulticlassCarver",
15
+ "OrdinalCarver",
14
16
  "ClassificationSelector",
17
+ "OrdinalSelector",
15
18
  "RegressionSelector",
16
19
  ]
@@ -3,5 +3,6 @@
3
3
  from AutoCarver.carvers.binary_carver import BinaryCarver
4
4
  from AutoCarver.carvers.continuous_carver import ContinuousCarver
5
5
  from AutoCarver.carvers.multiclass_carver import MulticlassCarver
6
+ from AutoCarver.carvers.ordinal_carver import OrdinalCarver
6
7
 
7
- __all__ = ["BinaryCarver", "ContinuousCarver", "MulticlassCarver"]
8
+ __all__ = ["BinaryCarver", "ContinuousCarver", "MulticlassCarver", "OrdinalCarver"]
@@ -89,6 +89,9 @@ class MulticlassCarver(BinaryCarver):
89
89
  X_dev: pd.DataFrame | None = None,
90
90
  y_dev: pd.Series | None = None,
91
91
  ) -> Self:
92
+ # dropping the target column if it leaked into the features (before versioning)
93
+ self._drop_target_from_features(X, y)
94
+
92
95
  # initiating samples
93
96
  samples = Samples(train=Sample(X, y), dev=Sample(X_dev, y_dev))
94
97
 
@@ -0,0 +1,101 @@
1
+ """Tool to build optimized buckets out of Quantitative and Qualitative features
2
+ for ordinal targets (ordered, integer-encoded modalities).
3
+ """
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+ from AutoCarver.carvers.binary_carver import get_crosstab
9
+ from AutoCarver.carvers.utils.base_carver import BaseCarver, Samples
10
+ from AutoCarver.combinations import CombinationEvaluator, KendallTauCCombinations
11
+ from AutoCarver.discretizers.utils.base_discretizer import ProcessingConfig
12
+ from AutoCarver.features import Features
13
+ from AutoCarver.utils import extend_docstring
14
+
15
+
16
+ class OrdinalCarver(BaseCarver):
17
+ """Automatic carving of continuous, discrete, categorical and ordinal
18
+ features that maximizes association with an **ordinal** target.
19
+
20
+ The target must be **integer-encoded** with ordered levels (e.g. ``1..K``,
21
+ ``K > 2``); the level order is taken from the ascending integer values.
22
+
23
+ For ordinal targets, Kendall's :ref:`tau_c` is the default association
24
+ measure to sort combinations — it rewards groupings whose order matches the
25
+ target's while favouring robust, parsimonious cardinality. :ref:`tau_b` and
26
+ the original Somers' D (:ref:`somersd`) are also available via
27
+ ``combination_evaluator``.
28
+ """
29
+
30
+ __name__ = "OrdinalCarver"
31
+ is_y_ordinal = True
32
+
33
+ @extend_docstring(BaseCarver.__init__, exclude=["combination_evaluator"])
34
+ def __init__(
35
+ self,
36
+ features: Features,
37
+ min_freq: float,
38
+ max_n_mod: int,
39
+ *,
40
+ combination_evaluator: CombinationEvaluator | None = None,
41
+ config: ProcessingConfig | None = None,
42
+ ) -> None:
43
+ """
44
+ Parameters
45
+ ----------
46
+ combination_evaluator : CombinationEvaluator, optional
47
+ Pre-built evaluator instance measuring association between
48
+ :class:`Features` and an ordinal target. Defaults to
49
+ :class:`KendallTauCCombinations`.
50
+
51
+ Choose from: :class:`KendallTauCCombinations` (default),
52
+ :class:`KendallTauBCombinations`, :class:`SomersDCombinations`.
53
+ """
54
+ if combination_evaluator is None:
55
+ combination_evaluator = KendallTauCCombinations()
56
+ if not combination_evaluator.is_y_ordinal:
57
+ raise ValueError(
58
+ f"[{self.__name__}] {type(combination_evaluator).__name__} is not suited for ordinal targets. "
59
+ f"Choose from: KendallTauCCombinations, KendallTauBCombinations, SomersDCombinations."
60
+ )
61
+
62
+ super().__init__(
63
+ features=features,
64
+ min_freq=min_freq,
65
+ max_n_mod=max_n_mod,
66
+ combination_evaluator=combination_evaluator,
67
+ config=config,
68
+ )
69
+
70
+ def _prepare_samples(self, samples: Samples) -> Samples:
71
+ """Validates format and content of X and y."""
72
+ if samples.train.y is None:
73
+ raise ValueError(f"[{self.__name__}] y must be provided")
74
+ if not pd.api.types.is_numeric_dtype(samples.train.y):
75
+ raise ValueError(
76
+ f"[{self.__name__}] y must be an integer-encoded ordinal Series; "
77
+ "integer-encode your ordered target (e.g. 1..K) before carving."
78
+ )
79
+
80
+ y_values = np.unique(samples.train.y)
81
+ if len(y_values) <= 2:
82
+ raise ValueError(f"[{self.__name__}] provided y has <=2 levels, consider using BinaryCarver instead.")
83
+ if not np.all(np.equal(np.mod(y_values, 1), 0)):
84
+ raise ValueError(
85
+ f"[{self.__name__}] y must be integer-encoded ordered levels (e.g. 1..K); got non-integer values."
86
+ )
87
+
88
+ return super()._prepare_samples(samples)
89
+
90
+ def _aggregator(self, X: pd.DataFrame, y: pd.Series) -> dict[str, pd.Series | pd.DataFrame | None]:
91
+ """Computes ordered contingency tables (feature modalities × ordinal target
92
+ levels) for specified features, ordered according to the known labels."""
93
+ # checking for empty datasets (dev)
94
+ xtabs = {feature.version: None for feature in self.features}
95
+ if X is not None:
96
+ # crosstab for each feature — pd.crosstab emits one column per ordinal
97
+ # level and sorts them ascending (correct ordinal column order)
98
+ for feature in self.features:
99
+ xtabs.update({feature.version: get_crosstab(X, y, feature)})
100
+
101
+ return xtabs
@@ -8,6 +8,7 @@ from dataclasses import dataclass, field, replace
8
8
  from functools import partial
9
9
  from multiprocessing import Pool
10
10
  from typing import Self
11
+ from warnings import warn
11
12
 
12
13
  import pandas as pd
13
14
 
@@ -89,7 +90,7 @@ def _drop_reason_from_history(history: pd.DataFrame) -> str:
89
90
  of historized non-viable combinations.
90
91
  """
91
92
  if history.empty:
92
- return "No combination historized"
93
+ return "No combination possible"
93
94
 
94
95
  info_counts: dict[str, int] = {}
95
96
  for _, row in history.iterrows():
@@ -120,6 +121,7 @@ class BaseCarver(BaseDiscretizer, ABC):
120
121
  is_y_binary = False
121
122
  is_y_continuous = False
122
123
  is_y_multiclass = False
124
+ is_y_ordinal = False
123
125
 
124
126
  @extend_docstring(BaseDiscretizer.__init__, exclude=["min_freq", "config"])
125
127
  def __init__(
@@ -293,6 +295,24 @@ class BaseCarver(BaseDiscretizer, ABC):
293
295
 
294
296
  return samples
295
297
 
298
+ def _drop_target_from_features(self, X: pd.DataFrame, y: pd.Series | None) -> None:
299
+ """Drops the target column from ``self.features`` if it leaked in.
300
+
301
+ ``Features.from_dataframe`` maps every column of the input, target included; the
302
+ target reaches the carver as the named ``y`` Series, so it is removed here rather
303
+ than at feature-construction time.
304
+
305
+ The guard fires only when ``y`` is genuinely a column of ``X`` (same name *and*
306
+ values): pandas propagates column names through arithmetic, so a derived target
307
+ like ``X[col] * 0.5 + noise`` can share a feature's name without being it.
308
+ """
309
+ if y is None or y.name is None:
310
+ return
311
+ name = str(y.name)
312
+ if name in self.features and name in X.columns and y.equals(X[name]):
313
+ warn(f"[{self.__name__}] dropping target column {name!r} from features", UserWarning, stacklevel=2)
314
+ self.features.remove(name)
315
+
296
316
  def fit( # type: ignore
297
317
  self,
298
318
  X: pd.DataFrame,
@@ -332,6 +352,9 @@ class BaseCarver(BaseDiscretizer, ABC):
332
352
  f"[{self.__name__}] features are already fitted or previous fit failed. Please reset your features."
333
353
  )
334
354
 
355
+ # dropping the target column if it leaked into the features
356
+ self._drop_target_from_features(X, y)
357
+
335
358
  # setting is_fitted
336
359
  self.features.is_fitted = True
337
360
 
@@ -9,6 +9,12 @@ from AutoCarver.combinations.continuous.continuous_combination_evaluators import
9
9
  ContinuousCombinationEvaluator,
10
10
  KruskalCombinations,
11
11
  )
12
+ from AutoCarver.combinations.ordinal.ordinal_combination_evaluators import (
13
+ KendallTauBCombinations,
14
+ KendallTauCCombinations,
15
+ OrdinalCombinationEvaluator,
16
+ SomersDCombinations,
17
+ )
12
18
  from AutoCarver.combinations.utils.combination_evaluator import CombinationEvaluator
13
19
 
14
20
  __all__ = [
@@ -18,4 +24,8 @@ __all__ = [
18
24
  "CramervCombinations",
19
25
  "ContinuousCombinationEvaluator",
20
26
  "KruskalCombinations",
27
+ "OrdinalCombinationEvaluator",
28
+ "KendallTauCCombinations",
29
+ "KendallTauBCombinations",
30
+ "SomersDCombinations",
21
31
  ]
@@ -0,0 +1,15 @@
1
+ """This module contains the ordinal combinations module."""
2
+
3
+ from AutoCarver.combinations.ordinal.ordinal_combination_evaluators import (
4
+ KendallTauBCombinations,
5
+ KendallTauCCombinations,
6
+ OrdinalCombinationEvaluator,
7
+ SomersDCombinations,
8
+ )
9
+
10
+ __all__ = [
11
+ "OrdinalCombinationEvaluator",
12
+ "KendallTauCCombinations",
13
+ "KendallTauBCombinations",
14
+ "SomersDCombinations",
15
+ ]
@@ -0,0 +1,424 @@
1
+ """Module for ordinal combination evaluators."""
2
+
3
+ import math
4
+ from abc import ABC
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+ from AutoCarver.combinations.ordinal.ordinal_target_rates import OrdinalTargetRate, TargetMeanLevel
10
+ from AutoCarver.combinations.utils.combination_evaluator import AggregatedSample, CombinationEvaluator
11
+ from AutoCarver.combinations.utils.combinations import combination_formatter, group_crosstab
12
+ from AutoCarver.combinations.utils.target_rate import TargetRate
13
+ from AutoCarver.features import GroupedList
14
+
15
+
16
+ class OrdinalCombinationEvaluator(CombinationEvaluator[pd.DataFrame], ABC):
17
+ """Ordinal combination evaluator class.
18
+
19
+ The aggregation is an ordered contingency table
20
+ ``feature-groups (rows, in target-rate order) × ordinal-target-levels
21
+ (cols, ascending)`` — the binary crosstab generalised from 2 columns to as
22
+ many columns as the target has levels. Three rank-association statistics are
23
+ computed per combination; concrete subclasses pick which one ranks
24
+ combinations via :attr:`sort_by`:
25
+
26
+ * :ref:`tau_c` — Kendall/Stuart's tau-c (**default**, rectangular-table
27
+ correction; self-balances to a robust, meaningful number of modalities);
28
+ * :ref:`tau_b` — Kendall's tau-b (matches :func:`scipy.stats.kendalltau`);
29
+ * :ref:`somersd` — the original asymmetric Somers' D ``D(Y|X)`` (target given
30
+ feature).
31
+
32
+ The symmetric Kendall taus reward a split only when it is genuinely
33
+ discriminative and otherwise favour fewer, more robust modalities — like
34
+ :class:`TschuprowtCombinations` and the Kruskal effect sizes. Somers' D is
35
+ asymmetric and leans strongly toward the coarsest split.
36
+
37
+ Search uses the inherited enumerate-and-score path.
38
+ """
39
+
40
+ is_y_ordinal = True
41
+ _target_rate_classes: list[type[OrdinalTargetRate]] = [TargetMeanLevel]
42
+ # narrow inherited attribute: ordinal evaluators always carry an OrdinalTargetRate
43
+ # (enforced by _init_target_rate).
44
+ target_rate: OrdinalTargetRate
45
+ # narrow inherited `sort_by: str | None`: concrete subclasses always set a str.
46
+ sort_by: str
47
+
48
+ def _init_target_rate(self, target_rate: TargetRate[pd.DataFrame] | None) -> OrdinalTargetRate:
49
+ """Initializes target rate."""
50
+ if target_rate is None:
51
+ return TargetMeanLevel()
52
+ if not isinstance(target_rate, OrdinalTargetRate):
53
+ raise ValueError("target_rate must be an OrdinalTargetRate")
54
+ return target_rate
55
+
56
+ def _grouper(self, xagg: AggregatedSample, groupby: dict) -> pd.DataFrame:
57
+ """Groups a crosstab by ``groupby`` and sums column values by group.
58
+
59
+ Shares :func:`group_crosstab` with the binary path: leaders are ordered
60
+ by first appearance so grouping stays independent of label text.
61
+ """
62
+ return group_crosstab(xagg, groupby)
63
+
64
+ def _association_measure(
65
+ self,
66
+ xagg: AggregatedSample | pd.Series | pd.DataFrame,
67
+ n_obs: int | None = None,
68
+ tol: float = 1e-10,
69
+ ) -> dict[str, float | None]:
70
+ """Computes Kendall's tau-b, tau-c and Somers' D between feature and target.
71
+
72
+ Parameters
73
+ ----------
74
+ xagg : pd.DataFrame
75
+ Ordered contingency table (rows = feature groups, cols = ordinal
76
+ target levels). ``n_obs`` / ``tol`` are unused (the rank statistics
77
+ only depend on the table's cell counts).
78
+
79
+ Returns
80
+ -------
81
+ dict[str, float | None]
82
+ ``{"tau_b": ..., "tau_c": ..., "somersd": ...}``; any may be
83
+ ``None`` for a degenerate table.
84
+ """
85
+ _, _ = n_obs, tol # unused
86
+ return _ordinal_associations(np.asarray(xagg.values, dtype=float))
87
+
88
+ def _get_best_combination_non_nan(self) -> dict | None:
89
+ """DP-based override with progressive top-K (mirrors the continuous path).
90
+
91
+ Replaces ``consecutive_combinations`` + enumerate-and-score with the
92
+ interval-DP in :func:`_top_k_partitions_ordinal_dp` over the additively
93
+ decomposable ``C−D`` numerator. Exact for tau-c (per-k constant
94
+ denominator); a progressively-grown top-K approximation for tau-b /
95
+ Somers' D, whose denominators depend on the group sizes.
96
+
97
+ The NaN path (:meth:`_get_best_combination_with_nan`) is **not** overridden:
98
+ it runs after this method has applied the best non-NaN grouping, so it
99
+ enumerates over the already-small grouped label set and the inherited
100
+ enumerate-and-score path is cheap there.
101
+ """
102
+ feature_labels = self.feature.labels
103
+ if feature_labels is None:
104
+ raise RuntimeError(f"[{self.__name__}] feature labels are not populated")
105
+ raw_labels = GroupedList(feature_labels[:])
106
+
107
+ if self.feature.has_nan:
108
+ if self.feature.dropna:
109
+ raw_labels.remove(self.feature.nan)
110
+ self.samples.dropna(self.feature.nan)
111
+
112
+ if self.samples.train.shape[0] <= 1:
113
+ return None
114
+
115
+ self._historize_raw_combination()
116
+
117
+ raw_index = list(raw_labels)
118
+ # samples.train.xagg is a crosstab DataFrame for ordinal evaluators
119
+ M, n_per_mod, col_sums = _dp_inputs_from_xagg(self.samples.train.xagg, raw_index) # type: ignore
120
+
121
+ # Progressive top-K with doubling, mirroring the binary/continuous DPs.
122
+ top_k = self.dp_top_k_initial
123
+ walked = 0
124
+ viable: dict | None = None
125
+ while True:
126
+ associations = _top_k_partitions_ordinal_dp(
127
+ M,
128
+ n_per_mod,
129
+ col_sums,
130
+ max_n_mod=self.max_n_mod,
131
+ raw_index=raw_index,
132
+ sort_by=self.sort_by,
133
+ top_k=top_k,
134
+ )
135
+ viable, walked = self._walk_for_viable(associations, start=walked)
136
+ if viable is not None:
137
+ break
138
+ if walked < top_k:
139
+ break # DP exhausted every consecutive partition; no viable exists
140
+ top_k *= 2
141
+
142
+ if viable is not None and viable.get("xagg") is None:
143
+ index_to_groupby = viable.get("index_to_groupby") or combination_formatter(viable["combination"])
144
+ viable["xagg"] = self._grouper(self.samples.train, index_to_groupby)
145
+
146
+ self._apply_best_combination(viable)
147
+ return viable
148
+
149
+
150
+ class KendallTauCCombinations(OrdinalCombinationEvaluator):
151
+ """Kendall's tau-c based combination evaluation toolkit (ordinal default).
152
+
153
+ Stuart's tau-c applies a ``min(r, c)`` correction tailored to **rectangular**
154
+ tables — exactly our shape (few feature groups × many target levels) — so its
155
+ magnitude stays comparable across combinations with different group counts and
156
+ it leans toward fewer, robust modalities, only adding one when a split is
157
+ genuinely meaningful.
158
+ """
159
+
160
+ sort_by = "tau_c"
161
+
162
+
163
+ class KendallTauBCombinations(OrdinalCombinationEvaluator):
164
+ """Kendall's tau-b based combination evaluation toolkit.
165
+
166
+ Bit-exact with :func:`scipy.stats.kendalltau` (the ``tau-b`` variant) on the
167
+ grouped contingency table — pinned by parity tests. Normalised by the
168
+ geometric mean of both margins' untied pairs; tends to retain more modalities
169
+ on smoothly monotone signals than :class:`KendallTauCCombinations`.
170
+ """
171
+
172
+ sort_by = "tau_b"
173
+
174
+
175
+ class SomersDCombinations(OrdinalCombinationEvaluator):
176
+ """Somers' D based combination evaluation toolkit.
177
+
178
+ The original asymmetric Somers' D ``D(Y|X)`` — concordant minus discordant
179
+ pairs over pairs untied on the feature ``X`` — matching
180
+ ``scipy.stats.somersd(table).statistic``. Being asymmetric it leans strongly
181
+ toward the coarsest split (its maximum over groupings is typically two
182
+ modalities); offered for users who specifically want raw Somers' D rather
183
+ than the self-balancing Kendall taus.
184
+ """
185
+
186
+ sort_by = "somersd"
187
+
188
+
189
+ def _concordant_minus_discordant(values: np.ndarray) -> float:
190
+ """Concordant minus discordant pairs ``C − D`` of an ordered table.
191
+
192
+ ``values`` is ``(r, c)`` with rows / columns already ascending.
193
+ """
194
+ # concordant partners of each cell: counts strictly down-right (k>i, l>j)
195
+ suffix = np.cumsum(np.cumsum(values[::-1, ::-1], axis=0), axis=1)[::-1, ::-1]
196
+ down_right = np.zeros_like(values)
197
+ down_right[:-1, :-1] = suffix[1:, 1:]
198
+
199
+ # discordant partners of each cell: counts strictly down-left (k>i, l<j)
200
+ suffix_rows_prefix_cols = np.cumsum(np.cumsum(values[::-1, :], axis=0)[::-1, :], axis=1)
201
+ down_left = np.zeros_like(values)
202
+ down_left[:-1, 1:] = suffix_rows_prefix_cols[1:, :-1]
203
+
204
+ return float((values * down_right).sum()) - float((values * down_left).sum())
205
+
206
+
207
+ def _taus_from_counts(
208
+ cd: float, n: float, untied_on_feature: float, untied_on_target: float, m: int
209
+ ) -> dict[str, float | None]:
210
+ """Assembles tau-b, tau-c and Somers' D from pre-computed pair counts.
211
+
212
+ Shared by the closed form (:func:`_ordinal_associations`) and the DP path
213
+ so both produce bit-identical values.
214
+
215
+ * ``tau_b = (C − D) / sqrt((P0 − T_X)(P0 − T_Y))`` — matches
216
+ ``scipy.stats.kendalltau``;
217
+ * ``tau_c = 2·m·(C − D) / (n²·(m − 1))`` (Stuart's rectangular-table
218
+ correction);
219
+ * ``somersd = (C − D) / (P0 − T_X)`` — the original Somers' D ``D(Y|X)``.
220
+
221
+ Each measure is ``None`` when its denominator vanishes.
222
+ """
223
+ denominator_b = math.sqrt(untied_on_feature * untied_on_target)
224
+ return {
225
+ "tau_b": cd / denominator_b if denominator_b > 0 else None,
226
+ "tau_c": (2.0 * m * cd) / (n * n * (m - 1)) if m > 1 else None,
227
+ "somersd": cd / untied_on_feature if untied_on_feature > 0 else None,
228
+ }
229
+
230
+
231
+ def _ordinal_associations(values: np.ndarray) -> dict[str, float | None]:
232
+ """Kendall's tau-b, tau-c and Somers' D ``D(Y|X)`` for an ordered table.
233
+
234
+ ``values`` is the ``(r, c)`` cell-count array with rows = ``X`` (feature
235
+ groups) and columns = ``Y`` (target levels), both already in ascending order.
236
+ Each measure is ``None`` when its denominator vanishes (degenerate table),
237
+ mirroring the continuous evaluator's ``None`` convention.
238
+ """
239
+ n = float(values.sum())
240
+ if n < 2:
241
+ return {"tau_b": None, "tau_c": None, "somersd": None}
242
+
243
+ cd = _concordant_minus_discordant(values)
244
+ row = values.sum(axis=1)
245
+ col = values.sum(axis=0)
246
+ all_pairs = n * (n - 1) / 2.0
247
+ untied_on_feature = all_pairs - float((row * (row - 1) / 2.0).sum())
248
+ untied_on_target = all_pairs - float((col * (col - 1) / 2.0).sum())
249
+ m = min(int((row > 0).sum()), int((col > 0).sum()))
250
+ return _taus_from_counts(cd, n, untied_on_feature, untied_on_target, m)
251
+
252
+
253
+ # ---------------------------------------------------------------------------
254
+ # Phase-B: progressive top-K interval DP over the additive C−D numerator
255
+ # ---------------------------------------------------------------------------
256
+ #
257
+ # C−D of a consecutive grouping decomposes additively:
258
+ #
259
+ # C−D(grouping) = TotalBetween − Σ_g WithinSegment(g)
260
+ #
261
+ # where TotalBetween (the C−D of the fully-split table) is constant and
262
+ # WithinSegment is prefix-summable. So an interval DP that keeps, per number of
263
+ # groups k, the partitions with the largest numerator (smallest Σ WithinSegment)
264
+ # enumerates the best candidates without materialising every consecutive
265
+ # partition. For tau-c the per-k denominator is constant, so numerator-optimal
266
+ # == metric-optimal (the DP is exact). For tau-b / Somers' D the denominator
267
+ # depends on the group sizes (T_X), so the kept top-K candidates are re-scored
268
+ # with their true denominators and ranked — exact when top_k is exhaustive, a
269
+ # top-K approximation otherwise.
270
+
271
+
272
+ def _dp_inputs_from_xagg(raw_xagg: pd.DataFrame, raw_index: list) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
273
+ """Aligns a raw crosstab to ``raw_index`` for the DP.
274
+
275
+ Returns ``(M, n_per_mod, col_sums)`` where ``M`` is the ``(len(raw_index), c)``
276
+ per-modality column-count matrix (rows absent from ``raw_xagg`` are zero),
277
+ ``n_per_mod`` the row totals and ``col_sums`` the target marginal.
278
+ """
279
+ position = {label: i for i, label in enumerate(raw_xagg.index)}
280
+ values = np.asarray(raw_xagg.values, dtype=float)
281
+ M = np.zeros((len(raw_index), values.shape[1]))
282
+ for row, label in enumerate(raw_index):
283
+ source = position.get(label)
284
+ if source is not None:
285
+ M[row] = values[source]
286
+ return M, M.sum(axis=1), M.sum(axis=0)
287
+
288
+
289
+ def _segment_within_costs(M: np.ndarray) -> np.ndarray:
290
+ """WithinSegment ``C−D`` for every consecutive row segment.
291
+
292
+ Returns ``seg`` of shape ``(n_mod, n_mod + 1)`` where ``seg[a, b]`` is the
293
+ concordant−discordant count among observations whose modalities both lie in
294
+ the consecutive block ``[a, b)`` — the within-segment pairs that grouping
295
+ removes from ``C−D``. Computed in ``O(n_mod² · c)`` via the bilinearity of
296
+ the between-modality concordance in the two rows' column vectors.
297
+ """
298
+ n_mod, c = M.shape
299
+ seg = np.zeros((n_mod, n_mod + 1))
300
+ for a in range(n_mod):
301
+ block = M[a].astype(float).copy()
302
+ within = 0.0
303
+ for b in range(a + 1, n_mod):
304
+ # between(block=[a,b) lower rows, row b higher): bilinear in column vectors
305
+ inclusive = np.cumsum(block)
306
+ strictly_lower = inclusive - block # target mass below each column, within the block
307
+ strictly_higher = block.sum() - inclusive # target mass above each column, within the block
308
+ within += float((M[b] * (strictly_lower - strictly_higher)).sum())
309
+ block = block + M[b]
310
+ seg[a, b + 1] = within
311
+ return seg
312
+
313
+
314
+ def _build_partition_dp(
315
+ seg: np.ndarray, *, n_mod: int, cap: int, top_k: int
316
+ ) -> list[list[list[tuple[float, tuple[int, ...]]]]]:
317
+ """dp[g][j]: up to ``top_k`` ``(sum_seg, splits)`` with the SMALLEST sum_seg
318
+ (largest numerator), where ``splits = (0, s_1, ..., s_{g-1}, j)`` and ``g`` is
319
+ the number of groups.
320
+ """
321
+ dp: list[list[list[tuple[float, tuple[int, ...]]]]] = [[[] for _ in range(n_mod + 1)] for _ in range(cap + 1)]
322
+ for j in range(1, n_mod + 1):
323
+ dp[1][j] = [(float(seg[0, j]), (0, j))]
324
+ for g in range(2, cap + 1):
325
+ for j in range(g, n_mod + 1):
326
+ candidates: list[tuple[float, tuple[int, ...]]] = []
327
+ for i in range(g - 1, j):
328
+ seg_ij = float(seg[i, j])
329
+ for prev_sum, prev_splits in dp[g - 1][i]:
330
+ candidates.append((prev_sum + seg_ij, prev_splits + (j,)))
331
+ if candidates:
332
+ candidates.sort(key=lambda x: x[0]) # smallest Σ within-segment first
333
+ dp[g][j] = candidates[:top_k]
334
+ return dp
335
+
336
+
337
+ def _score_partition(
338
+ sum_seg: float,
339
+ splits: tuple[int, ...],
340
+ *,
341
+ total_between: float,
342
+ n_prefix: np.ndarray,
343
+ total_n: float,
344
+ all_pairs: float,
345
+ untied_on_target: float,
346
+ c_nonempty: int,
347
+ ) -> dict:
348
+ """Compute tau_b / tau_c / somersd for a single consecutive partition."""
349
+ cd = total_between - sum_seg
350
+ tied_on_feature = 0.0
351
+ non_empty_groups = 0
352
+ for g in range(len(splits) - 1):
353
+ size = n_prefix[splits[g + 1]] - n_prefix[splits[g]]
354
+ tied_on_feature += size * (size - 1) / 2.0
355
+ if size > 0:
356
+ non_empty_groups += 1
357
+ # m matches the closed form: min over *non-empty* grouped rows and target levels
358
+ m = min(non_empty_groups, c_nonempty)
359
+ return _taus_from_counts(cd, total_n, all_pairs - tied_on_feature, untied_on_target, m)
360
+
361
+
362
+ def _top_k_partitions_ordinal_dp(
363
+ M: np.ndarray,
364
+ n_per_mod: np.ndarray,
365
+ col_sums: np.ndarray,
366
+ *,
367
+ max_n_mod: int,
368
+ raw_index: list,
369
+ sort_by: str,
370
+ top_k: int,
371
+ ) -> list[dict]:
372
+ """Top-K consecutive partitions ranked by ``sort_by`` (tau_b / tau_c / somersd).
373
+
374
+ ``M`` is the ``(n_mod, c)`` per-modality column-count matrix aligned to
375
+ ``raw_index``; ``col_sums`` is the target marginal (for ``T_Y``). Returns a
376
+ list of ``{combination, index_to_groupby, tau_b, tau_c, somersd}`` dicts
377
+ sorted by ``sort_by`` desc — same shape the streaming pipeline yields, so it
378
+ drops into the viability walk.
379
+ """
380
+ n_mod = len(raw_index)
381
+ cap = min(max_n_mod, n_mod)
382
+ total_n = float(n_per_mod.sum())
383
+ if cap < 2 or total_n < 2:
384
+ return []
385
+
386
+ all_pairs = total_n * (total_n - 1) / 2.0
387
+ untied_on_target = all_pairs - float((col_sums * (col_sums - 1) / 2.0).sum())
388
+ c_nonempty = int((col_sums > 0).sum())
389
+ total_between = _concordant_minus_discordant(M)
390
+ seg = _segment_within_costs(M)
391
+ n_prefix = np.concatenate([[0.0], np.cumsum(n_per_mod.astype(float))])
392
+
393
+ dp = _build_partition_dp(seg, n_mod=n_mod, cap=cap, top_k=top_k)
394
+
395
+ entries: list[tuple[float, dict, tuple[int, ...]]] = []
396
+ for k in range(2, cap + 1):
397
+ for sum_seg, splits in dp[k][n_mod]:
398
+ metrics = _score_partition(
399
+ sum_seg,
400
+ splits,
401
+ total_between=total_between,
402
+ n_prefix=n_prefix,
403
+ total_n=total_n,
404
+ all_pairs=all_pairs,
405
+ untied_on_target=untied_on_target,
406
+ c_nonempty=c_nonempty,
407
+ )
408
+ entries.append((_sort_key(metrics.get(sort_by)), metrics, splits))
409
+
410
+ entries.sort(key=lambda e: e[0], reverse=True)
411
+ entries = entries[:top_k]
412
+
413
+ out: list[dict] = []
414
+ for _, metrics, splits in entries:
415
+ combination = [list(raw_index[splits[g] : splits[g + 1]]) for g in range(len(splits) - 1)]
416
+ out.append({"combination": combination, "index_to_groupby": combination_formatter(combination), **metrics})
417
+ return out
418
+
419
+
420
+ def _sort_key(value: float | None) -> float:
421
+ """Sort key putting ``None`` / ``NaN`` metrics last (descending sort)."""
422
+ if value is None or (isinstance(value, float) and math.isnan(value)):
423
+ return float("-inf")
424
+ return float(value)