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 +30 -0
- segmtree/_base.py +94 -0
- segmtree/rules.py +215 -0
- segmtree/stability.py +100 -0
- segmtree/tree.py +956 -0
- segmtree-0.3.0.dist-info/METADATA +218 -0
- segmtree-0.3.0.dist-info/RECORD +9 -0
- segmtree-0.3.0.dist-info/WHEEL +4 -0
- segmtree-0.3.0.dist-info/licenses/LICENSE +21 -0
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
|
+
)
|