segmtree 0.3.0__py3-none-any.whl

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.
segmtree/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """segmtree: interpretable unsupervised decision trees for segmentation.
2
+
3
+ Partitions rows of a feature matrix into segments described by short,
4
+ human-readable rule chains, without any target variable. See
5
+ :class:`SegTree` for the estimator and the README for a full walkthrough.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .rules import Condition, Rule, extract_rules, profile_leaves, replay_check, replay_labels
11
+ from .stability import StabilityResult, adjusted_rand_score, bootstrap_stability
12
+ from .tree import Cut, Leaf, SegTree
13
+
14
+ __version__ = "0.3.0"
15
+
16
+ __all__ = [
17
+ "Condition",
18
+ "Cut",
19
+ "Leaf",
20
+ "Rule",
21
+ "SegTree",
22
+ "StabilityResult",
23
+ "__version__",
24
+ "adjusted_rand_score",
25
+ "bootstrap_stability",
26
+ "extract_rules",
27
+ "profile_leaves",
28
+ "replay_check",
29
+ "replay_labels",
30
+ ]
segmtree/_base.py ADDED
@@ -0,0 +1,94 @@
1
+ """Shared infrastructure: sklearn-compatible base class and input validation.
2
+
3
+ The package's only runtime dependency is numpy. When scikit-learn is
4
+ installed we reuse its ``BaseEstimator`` for full ecosystem compatibility
5
+ (``clone``, pipelines, hyperparameter search); otherwise a minimal,
6
+ API-compatible fallback is used.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import inspect
12
+
13
+ import numpy as np
14
+
15
+ __all__ = ["BaseEstimator", "as_2d_array"]
16
+
17
+ try: # pragma: no cover - exercised implicitly depending on environment
18
+ from sklearn.base import BaseEstimator
19
+
20
+ _HAS_SKLEARN = True
21
+ except ImportError: # pragma: no cover
22
+
23
+ class BaseEstimator:
24
+ """Minimal stand-in for :class:`sklearn.base.BaseEstimator`."""
25
+
26
+ def get_params(self, deep=True):
27
+ params = {}
28
+ for name, p in inspect.signature(type(self).__init__).parameters.items():
29
+ if name == "self" or p.kind in (
30
+ inspect.Parameter.VAR_POSITIONAL,
31
+ inspect.Parameter.VAR_KEYWORD,
32
+ ):
33
+ continue
34
+ params[name] = getattr(self, name)
35
+ return params
36
+
37
+ def set_params(self, **params):
38
+ valid = self.get_params(deep=False)
39
+ for key, value in params.items():
40
+ if key not in valid:
41
+ raise ValueError(
42
+ f"Invalid parameter {key!r} for {type(self).__name__}. "
43
+ f"Valid parameters are: {sorted(valid)}."
44
+ )
45
+ setattr(self, key, value)
46
+ return self
47
+
48
+ def __repr__(self):
49
+ parts = []
50
+ for name in self.get_params(deep=False):
51
+ try:
52
+ default = inspect.signature(type(self).__init__).parameters[name].default
53
+ current = getattr(self, name)
54
+ equal = (
55
+ bool(np.all(current == default))
56
+ if isinstance(current, np.ndarray)
57
+ else (current == default)
58
+ )
59
+ except Exception:
60
+ equal = True
61
+ if not equal:
62
+ parts.append(f"{name}={getattr(self, name)!r}")
63
+ return f"{type(self).__name__}({', '.join(parts)})"
64
+
65
+
66
+ def as_2d_array(X, *, context="X"):
67
+ """Coerce tabular input to a 2-D finite numeric ndarray.
68
+
69
+ Accepts anything ``np.asarray`` understands plus objects exposing
70
+ ``to_numpy`` (e.g. pandas DataFrames). Returns ``(array, column_names_or_None)``.
71
+ """
72
+ columns = None
73
+ if hasattr(X, "to_numpy") and hasattr(X, "columns"):
74
+ columns = [str(c) for c in X.columns]
75
+ X = X.to_numpy()
76
+ arr = np.asarray(X)
77
+ if arr.dtype == object:
78
+ try:
79
+ arr = arr.astype(np.float64)
80
+ except (TypeError, ValueError) as e:
81
+ raise TypeError(f"{context} must be numeric; got dtype=object ({e})") from e
82
+ if not np.issubdtype(arr.dtype, np.number):
83
+ raise TypeError(f"{context} must have a numeric dtype, got {arr.dtype}.")
84
+ if arr.ndim != 2:
85
+ raise ValueError(f"{context} must be 2-D (n_samples, n_features), got shape {arr.shape}.")
86
+ if arr.shape[0] == 0 or arr.shape[1] == 0:
87
+ raise ValueError(f"{context} must not be empty, got shape {arr.shape}.")
88
+ if not np.isfinite(arr).all():
89
+ n_bad = int((~np.isfinite(arr)).sum())
90
+ raise ValueError(
91
+ f"{context} contains {n_bad} NaN/infinite value(s). "
92
+ "Impute or drop them before fitting (segmtree does no imputation)."
93
+ )
94
+ return arr, columns
segmtree/rules.py ADDED
@@ -0,0 +1,215 @@
1
+ """Rule extraction, replay validation and segment profiling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ import numpy as np
8
+
9
+ from ._base import as_2d_array
10
+
11
+ __all__ = [
12
+ "Condition",
13
+ "Rule",
14
+ "extract_rules",
15
+ "profile_leaves",
16
+ "replay_check",
17
+ "replay_labels",
18
+ "rule_mask",
19
+ ]
20
+
21
+
22
+ def _fmt(value):
23
+ if float(value).is_integer() and abs(value) < 2**53:
24
+ return str(int(value))
25
+ return f"{value:g}"
26
+
27
+
28
+ def _fmt_codes(categories):
29
+ return "{" + ", ".join(_fmt(float(c)) for c in sorted(categories)) + "}"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Condition:
34
+ """One axis-aligned test.
35
+
36
+ ``op`` is ``"<="``/``">"`` with a float ``threshold``, or ``"in"`` /
37
+ ``"not in"`` with integer category codes in ``categories``.
38
+ """
39
+
40
+ feature: str
41
+ op: str
42
+ threshold: float = float("nan")
43
+ categories: tuple = field(default=())
44
+
45
+ def __post_init__(self):
46
+ if self.op not in ("<=", ">", "in", "not in"):
47
+ raise ValueError(f"Unknown condition op: {self.op!r}.")
48
+
49
+ def __str__(self):
50
+ if self.op in ("in", "not in"):
51
+ return f"{self.feature} {self.op} {_fmt_codes(self.categories)}"
52
+ return f"{self.feature} {self.op} {_fmt(self.threshold)}"
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Rule:
57
+ """The full rule chain defining one segment (leaf)."""
58
+
59
+ leaf_id: int
60
+ conditions: tuple
61
+ n_samples: int
62
+ fraction: float
63
+ depth: int
64
+
65
+ @property
66
+ def text(self):
67
+ return " AND ".join(str(c) for c in self.conditions) if self.conditions else "TRUE"
68
+
69
+ def __str__(self):
70
+ return f"#{self.leaf_id}: {self.text}"
71
+
72
+
73
+ def extract_rules(tree):
74
+ """Return the :class:`Rule` for every fitted segment, in leaf-id order."""
75
+ if not hasattr(tree, "labels_"):
76
+ raise RuntimeError("This SegTree instance is not fitted yet; call fit() first.")
77
+ rules = []
78
+ for lf in tree.leaves_:
79
+ conds = []
80
+ for name, op, payload in lf.conditions:
81
+ if op in ("in", "not in"):
82
+ conds.append(Condition(name, op, categories=tuple(sorted(payload))))
83
+ else:
84
+ conds.append(Condition(name, op, threshold=float(payload)))
85
+ rules.append(
86
+ Rule(
87
+ leaf_id=lf.id,
88
+ conditions=tuple(conds),
89
+ n_samples=lf.n_samples,
90
+ fraction=lf.fraction,
91
+ depth=lf.depth,
92
+ )
93
+ )
94
+ return rules
95
+
96
+
97
+ def rule_mask(rule, X, feature_names):
98
+ """Boolean mask of rows satisfying ``rule``; evaluated independently of the tree."""
99
+ mask = np.ones(X.shape[0], dtype=bool)
100
+ index = {nm: i for i, nm in enumerate(feature_names)}
101
+ for cond in rule.conditions:
102
+ j = index[cond.feature]
103
+ col = X[:, j]
104
+ if cond.op == "<=":
105
+ mask &= col <= cond.threshold
106
+ elif cond.op == ">":
107
+ mask &= col > cond.threshold
108
+ elif cond.op == "in":
109
+ mask &= np.isin(col, cond.categories)
110
+ else: # "not in"
111
+ mask &= ~np.isin(col, cond.categories)
112
+ return mask
113
+
114
+
115
+ def replay_labels(tree, X):
116
+ """Assign labels by replaying every rule chain on ``X``.
117
+
118
+ Independent of :meth:`SegTree.predict` (no tree traversal), so agreement
119
+ between the two validates that stored rules reproduce the partition.
120
+ Rows matching no rule receive -1. Honors ``tree.handle_unknown``: with
121
+ ``"error"`` unseen category codes raise ``ValueError``.
122
+ """
123
+ Xarr, _ = as_2d_array(X, context="X")
124
+ if (
125
+ getattr(tree, "handle_unknown", "complement") == "error"
126
+ and getattr(tree, "categorical_indices_", np.array([])).size
127
+ ):
128
+ cat_codes = tree._encode_input_categories(Xarr)
129
+ tree._check_unknown(Xarr, cat_codes)
130
+ out = np.full(Xarr.shape[0], -1, dtype=np.intp)
131
+ covered = np.zeros(Xarr.shape[0], dtype=bool)
132
+ for rule in extract_rules(tree):
133
+ m = rule_mask(rule, Xarr, tree.feature_names_)
134
+ out[m & ~covered] = rule.leaf_id
135
+ covered |= m
136
+ return out
137
+
138
+
139
+ def replay_check(tree, X):
140
+ """True if rule replay reproduces ``tree.predict(X)`` exactly."""
141
+ return bool(np.array_equal(replay_labels(tree, X), tree.predict(X)))
142
+
143
+
144
+ def profile_leaves(tree, X, top_k=3):
145
+ """Profile each segment by its most distinctive features.
146
+
147
+ For every leaf, computes the centroid of ``X`` rows assigned to it.
148
+ Continuous/binary features are ranked by absolute z-score versus the
149
+ whole dataset (positive = above average). Categorical columns are *not*
150
+ z-scored; instead each leaf receives ``category_enrichment``: the most
151
+ enriched (feature, code) pairs by lift of in-leaf share over global share
152
+ (only pairs covering >= 5% of the leaf, lift > 1, capped at ``top_k``,
153
+ sorted by descending lift).
154
+
155
+ Returns a list of dicts with keys ``leaf_id``, ``n_samples``, ``fraction``,
156
+ ``top_features`` (tuple of ``(feature_name, z_score)`` for non-categorical
157
+ features only) and ``category_enrichment`` (tuple of
158
+ ``(feature_name, code, leaf_share, lift)``; empty when the tree has no
159
+ categorical features).
160
+ """
161
+ Xarr, _ = as_2d_array(X, context="X")
162
+ labels = tree.predict(Xarr)
163
+ cat_idx = set(int(j) for j in getattr(tree, "categorical_indices_", []))
164
+ num_cols = [j for j in range(Xarr.shape[1]) if j not in cat_idx]
165
+ mu = Xarr.mean(axis=0)
166
+ sd = Xarr.std(axis=0)
167
+ sd[sd < 1e-12] = 1e-12
168
+
169
+ # global category shares for lift computation
170
+ global_dist = {}
171
+ for j in cat_idx:
172
+ known = np.asarray(tree.categories_[tree.feature_names_[j]])
173
+ local = np.searchsorted(known, np.rint(Xarr[:, j]).astype(np.int64))
174
+ counts = np.bincount(local, minlength=known.size + 1)[: known.size]
175
+ global_dist[j] = counts / max(counts.sum(), 1)
176
+
177
+ profiles = []
178
+ for lf in tree.leaves_:
179
+ rows_mask = labels == lf.id
180
+ sub = Xarr[rows_mask]
181
+ enrichment = []
182
+ for j in cat_idx:
183
+ known = np.asarray(tree.categories_[tree.feature_names_[j]])
184
+ local = np.searchsorted(known, np.rint(sub[:, j]).astype(np.int64))
185
+ counts = np.bincount(local, minlength=known.size + 1)[: known.size]
186
+ if counts.sum() == 0:
187
+ continue
188
+ leaf_share = counts / counts.sum()
189
+ lift = leaf_share / np.maximum(global_dist[j], 1e-12)
190
+ eligible = np.flatnonzero((leaf_share >= 0.05) & (lift > 1.0))
191
+ for pos in eligible:
192
+ enrichment.append(
193
+ (
194
+ tree.feature_names_[j],
195
+ int(known[pos]),
196
+ float(leaf_share[pos]),
197
+ float(lift[pos]),
198
+ )
199
+ )
200
+ enrichment.sort(key=lambda item: -item[3])
201
+ zcols = sub[:, num_cols] if num_cols else np.empty((len(sub), 0))
202
+ znum = (zcols.mean(axis=0) - mu[num_cols]) / sd[num_cols] if num_cols else ()
203
+ top_order = np.argsort(-np.abs(znum))[:top_k] if num_cols else []
204
+ profiles.append(
205
+ {
206
+ "leaf_id": lf.id,
207
+ "n_samples": int(sub.shape[0]),
208
+ "fraction": sub.shape[0] / Xarr.shape[0],
209
+ "top_features": tuple(
210
+ (tree.feature_names_[num_cols[p]], float(znum[p])) for p in top_order
211
+ ),
212
+ "category_enrichment": tuple(enrichment[:top_k]),
213
+ }
214
+ )
215
+ return profiles
segmtree/stability.py ADDED
@@ -0,0 +1,100 @@
1
+ """Bootstrap stability assessment for fitted trees."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+
9
+ from ._base import as_2d_array
10
+ from .tree import SegTree
11
+
12
+ __all__ = ["StabilityResult", "adjusted_rand_score", "bootstrap_stability"]
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class StabilityResult:
17
+ """Bootstrap stability of a segmentation.
18
+
19
+ ``scores[i]`` is the Adjusted Rand Index between the original partition
20
+ and the partition induced on the full data by a tree refit on bootstrap
21
+ replicate ``i``. Values above ~0.7 indicate a stable structure.
22
+ """
23
+
24
+ scores: np.ndarray
25
+ mean: float
26
+ std: float
27
+ n_replicates: int
28
+
29
+
30
+ def adjusted_rand_score(labels_a, labels_b):
31
+ """Adjusted Rand Index between two labelings (pure numpy).
32
+
33
+ Matches :func:`sklearn.metrics.adjusted_rand_score` numerically; provided
34
+ to keep the package dependency-free.
35
+ """
36
+ a = np.asarray(labels_a).ravel()
37
+ b = np.asarray(labels_b).ravel()
38
+ if a.shape[0] != b.shape[0]:
39
+ raise ValueError("Label arrays must have the same length.")
40
+ _, ai = np.unique(a, return_inverse=True)
41
+ _, bi = np.unique(b, return_inverse=True)
42
+ na, nb, n = int(ai.max()) + 1, int(bi.max()) + 1, a.shape[0]
43
+ contingency = np.bincount(ai * nb + bi, minlength=na * nb).astype(np.int64).reshape(na, nb)
44
+
45
+ def comb2(x):
46
+ return x * (x - 1) // 2
47
+
48
+ sum_comb = comb2(contingency.astype(np.float64)).sum()
49
+ sum_a = comb2(contingency.sum(axis=1).astype(np.float64)).sum()
50
+ sum_b = comb2(contingency.sum(axis=0).astype(np.float64)).sum()
51
+ expected = sum_a * sum_b / comb2(float(n))
52
+ max_index = 0.5 * (sum_a + sum_b)
53
+ denom = max_index - expected
54
+ if denom == 0.0:
55
+ return 1.0
56
+ return float((sum_comb - expected) / denom)
57
+
58
+
59
+ def bootstrap_stability(tree, X, *, n_replicates=12, random_state=None):
60
+ """Refit the tree on bootstrap resamples and measure agreement with the fit.
61
+
62
+ Parameters
63
+ ----------
64
+ tree : SegTree
65
+ A fitted tree; its hyperparameters are reused for every replicate.
66
+ X : array-like, shape (n_samples, n_features)
67
+ The training matrix used to fit ``tree``.
68
+ n_replicates : int, default=12
69
+ Number of bootstrap replicates.
70
+ random_state : int or None, default=None
71
+ Seed for reproducibility.
72
+
73
+ Returns
74
+ -------
75
+ StabilityResult
76
+ """
77
+ if not hasattr(tree, "labels_"):
78
+ raise RuntimeError("tree must be fitted before calling bootstrap_stability().")
79
+ if isinstance(n_replicates, bool) or not isinstance(n_replicates, int) or n_replicates < 1:
80
+ raise ValueError("n_replicates must be an int >= 1.")
81
+ Xarr, _ = as_2d_array(X, context="X")
82
+ if Xarr.shape != (tree.n_samples_in_, tree.n_features_in_):
83
+ raise ValueError(
84
+ f"X must be the training matrix with shape "
85
+ f"{(tree.n_samples_in_, tree.n_features_in_)}, got {Xarr.shape}."
86
+ )
87
+ params = tree.get_params()
88
+ base_labels = tree.labels_
89
+ rng = np.random.default_rng(random_state)
90
+ scores = np.empty(n_replicates, dtype=np.float64)
91
+ for b in range(n_replicates):
92
+ idx = rng.integers(0, Xarr.shape[0], Xarr.shape[0])
93
+ replica = SegTree(**params).fit(Xarr[idx])
94
+ scores[b] = adjusted_rand_score(base_labels, replica.predict(Xarr))
95
+ return StabilityResult(
96
+ scores=scores,
97
+ mean=float(scores.mean()),
98
+ std=float(scores.std()),
99
+ n_replicates=n_replicates,
100
+ )