tide-binning 0.6.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.
- tide/__init__.py +10 -0
- tide/_bins.py +178 -0
- tide/_categorical.py +200 -0
- tide/_estimator.py +464 -0
- tide/_fit.py +256 -0
- tide/_greedy.py +262 -0
- tide/_metrics.py +53 -0
- tide/_parse.py +228 -0
- tide/_stats.py +135 -0
- tide/plotting.py +138 -0
- tide_binning-0.6.0.dist-info/METADATA +194 -0
- tide_binning-0.6.0.dist-info/RECORD +14 -0
- tide_binning-0.6.0.dist-info/WHEEL +4 -0
- tide_binning-0.6.0.dist-info/licenses/LICENSE +21 -0
tide/__init__.py
ADDED
tide/_bins.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""The bins format: validation, row assignment and labels.
|
|
2
|
+
|
|
3
|
+
A bins dict looks like::
|
|
4
|
+
|
|
5
|
+
{"trend": "pos",
|
|
6
|
+
"bins": {0: {(-np.inf, 0.34): "cont", "special_category": "cat"},
|
|
7
|
+
1: {(0.34, 0.65): "cont"},
|
|
8
|
+
2: {(0.65, np.inf): "cont"},
|
|
9
|
+
3: {None: "missing"}}}
|
|
10
|
+
|
|
11
|
+
Continuous intervals are ``(a, b]`` for ``trend="pos"`` and ``[a, b)`` for
|
|
12
|
+
``trend="neg"``. Infinite bounds are always inclusive. ``None`` is the key for
|
|
13
|
+
NaN / None / pd.NA / NaT.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import numbers
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from ._parse import ParsedColumn, normalize_key
|
|
24
|
+
|
|
25
|
+
__all__ = ["CompiledBins", "bin_label", "compile_bins", "special_keys", "validate_bins"]
|
|
26
|
+
|
|
27
|
+
ATOM_TYPES = ("cont", "cat", "missing")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_bins(bins: dict, feature: str) -> dict:
|
|
31
|
+
"""Check a user bins dict and return a normalized copy."""
|
|
32
|
+
where = f"user_bins[{feature!r}]"
|
|
33
|
+
if not isinstance(bins, dict) or "bins" not in bins or not isinstance(bins["bins"], dict):
|
|
34
|
+
raise ValueError(f"{where} must be a dict with a 'bins' dict")
|
|
35
|
+
if not bins["bins"]:
|
|
36
|
+
raise ValueError(f"{where} has no bins")
|
|
37
|
+
out: dict = {}
|
|
38
|
+
has_cont = False
|
|
39
|
+
for idx, atoms in bins["bins"].items():
|
|
40
|
+
if not isinstance(idx, (int, np.integer)) or isinstance(idx, bool):
|
|
41
|
+
raise ValueError(f"{where}: bin index {idx!r} must be an int")
|
|
42
|
+
if not isinstance(atoms, dict) or not atoms:
|
|
43
|
+
raise ValueError(f"{where}: bin {idx} must be a non-empty dict of atom -> type")
|
|
44
|
+
norm = {}
|
|
45
|
+
for atom, kind in atoms.items():
|
|
46
|
+
if kind not in ATOM_TYPES:
|
|
47
|
+
raise ValueError(f"{where}: bin {idx} has atom type {kind!r}; use {ATOM_TYPES}")
|
|
48
|
+
if kind == "cont":
|
|
49
|
+
ok = (
|
|
50
|
+
isinstance(atom, tuple)
|
|
51
|
+
and len(atom) == 2
|
|
52
|
+
and all(isinstance(v, numbers.Real) and not isinstance(v, bool) for v in atom)
|
|
53
|
+
and not np.isnan(atom[0])
|
|
54
|
+
and not np.isnan(atom[1])
|
|
55
|
+
)
|
|
56
|
+
if not ok or not atom[0] < atom[1]:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"{where}: bin {idx} has invalid interval {atom!r}; use (a, b) with a < b"
|
|
59
|
+
)
|
|
60
|
+
has_cont = True
|
|
61
|
+
norm[(float(atom[0]), float(atom[1]))] = "cont"
|
|
62
|
+
else:
|
|
63
|
+
norm[normalize_key(atom)] = kind
|
|
64
|
+
out[int(idx)] = norm
|
|
65
|
+
trend = bins.get("trend")
|
|
66
|
+
if has_cont and trend not in ("pos", "neg"):
|
|
67
|
+
raise ValueError(f"{where} has continuous intervals, so 'trend' must be 'pos' or 'neg'")
|
|
68
|
+
result = {"trend": trend if trend in ("pos", "neg") else "pos", "bins": out}
|
|
69
|
+
compile_bins(result, feature) # checks overlaps and duplicate keys
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def special_keys(bins: dict) -> tuple[list, list]:
|
|
74
|
+
"""Numeric keys typed 'cat' / 'missing' in a bins dict (e.g. -999)."""
|
|
75
|
+
cat, miss = [], []
|
|
76
|
+
for atoms in bins["bins"].values():
|
|
77
|
+
for atom, kind in atoms.items():
|
|
78
|
+
if kind != "cont" and isinstance(atom, numbers.Real) and not isinstance(atom, bool):
|
|
79
|
+
(cat if kind == "cat" else miss).append(atom)
|
|
80
|
+
return cat, miss
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class CompiledBins:
|
|
85
|
+
"""A bins dict in a form ready for vectorized assignment."""
|
|
86
|
+
|
|
87
|
+
trend: str
|
|
88
|
+
bin_ids: list # bin index (dict key) per position
|
|
89
|
+
a: np.ndarray # interval lower bounds, sorted
|
|
90
|
+
b: np.ndarray # interval upper bounds
|
|
91
|
+
g: np.ndarray # position of the bin owning each interval
|
|
92
|
+
key_map: dict # cat / missing key -> position
|
|
93
|
+
|
|
94
|
+
def assign_values(self, x: np.ndarray) -> np.ndarray:
|
|
95
|
+
"""Bin position of each continuous value, or -1 if it falls in no interval."""
|
|
96
|
+
if not self.a.size:
|
|
97
|
+
return np.full(x.shape[0], -1, dtype=np.int64)
|
|
98
|
+
if self.trend == "pos": # (a, b]
|
|
99
|
+
i = np.searchsorted(self.b, x, side="left")
|
|
100
|
+
valid = i < self.b.size
|
|
101
|
+
i = np.minimum(i, self.b.size - 1)
|
|
102
|
+
ok = valid & ((x > self.a[i]) | np.isneginf(self.a[i]))
|
|
103
|
+
else: # [a, b)
|
|
104
|
+
i = np.searchsorted(self.a, x, side="right") - 1
|
|
105
|
+
valid = i >= 0
|
|
106
|
+
i = np.maximum(i, 0)
|
|
107
|
+
ok = valid & ((x < self.b[i]) | np.isposinf(self.b[i]))
|
|
108
|
+
return np.where(ok, self.g[i], -1)
|
|
109
|
+
|
|
110
|
+
def assign(self, parsed: ParsedColumn, cont_codes: np.ndarray | None = None) -> np.ndarray:
|
|
111
|
+
"""Bin position per row, or -1 for values that fall in no bin."""
|
|
112
|
+
out = np.full(parsed.n, -1, dtype=np.int64)
|
|
113
|
+
if parsed.x_cont.size:
|
|
114
|
+
if cont_codes is None:
|
|
115
|
+
cont_codes = self.assign_values(parsed.x_cont)
|
|
116
|
+
out[parsed.cont_mask] = cont_codes
|
|
117
|
+
for codes, keys in (
|
|
118
|
+
(parsed.cat_codes, parsed.cat_keys),
|
|
119
|
+
(parsed.miss_codes, parsed.miss_keys),
|
|
120
|
+
):
|
|
121
|
+
if not keys:
|
|
122
|
+
continue
|
|
123
|
+
lut = np.array([self.key_map.get(k, -1) for k in keys] + [-1], dtype=np.int64)
|
|
124
|
+
m = codes >= 0
|
|
125
|
+
out[m] = lut[codes[m]]
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def compile_bins(bins: dict, feature: str = "") -> CompiledBins:
|
|
130
|
+
"""Compile a bins dict; raises on overlapping intervals or duplicate keys."""
|
|
131
|
+
bin_ids = sorted(bins["bins"])
|
|
132
|
+
pos_of = {b: i for i, b in enumerate(bin_ids)}
|
|
133
|
+
intervals, key_map = [], {}
|
|
134
|
+
for b, atoms in bins["bins"].items():
|
|
135
|
+
for atom, kind in atoms.items():
|
|
136
|
+
if kind == "cont":
|
|
137
|
+
intervals.append((atom[0], atom[1], pos_of[b]))
|
|
138
|
+
else:
|
|
139
|
+
if atom in key_map:
|
|
140
|
+
raise ValueError(f"{feature}: key {atom!r} appears in more than one bin")
|
|
141
|
+
key_map[atom] = pos_of[b]
|
|
142
|
+
intervals.sort()
|
|
143
|
+
a = np.array([t[0] for t in intervals], dtype=np.float64)
|
|
144
|
+
bb = np.array([t[1] for t in intervals], dtype=np.float64)
|
|
145
|
+
g = np.array([t[2] for t in intervals], dtype=np.int64)
|
|
146
|
+
if a.size > 1 and np.any(a[1:] < bb[:-1]):
|
|
147
|
+
raise ValueError(f"{feature}: continuous intervals overlap")
|
|
148
|
+
return CompiledBins(bins.get("trend", "pos"), bin_ids, a, bb, g, key_map)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _fmt(v: float, decimals: int = 4) -> str:
|
|
152
|
+
if np.isinf(v):
|
|
153
|
+
return "inf" if v > 0 else "-inf"
|
|
154
|
+
if float(v).is_integer():
|
|
155
|
+
return str(int(v))
|
|
156
|
+
return f"{v:.{decimals}f}"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def bin_label(atoms: dict, trend: str, decimals: int = 4) -> str:
|
|
160
|
+
"""Display label of a bin, e.g. ``(-inf,0.3400]; special_category``.
|
|
161
|
+
|
|
162
|
+
Integer bounds print without decimals (``(18,25]``); other bounds print with exactly
|
|
163
|
+
``decimals`` decimal places, trailing zeros kept. Only the label is rounded: the bins
|
|
164
|
+
themselves keep the full values.
|
|
165
|
+
"""
|
|
166
|
+
parts = []
|
|
167
|
+
for atom, kind in atoms.items():
|
|
168
|
+
if kind == "cont":
|
|
169
|
+
a, b = atom
|
|
170
|
+
if trend == "pos":
|
|
171
|
+
right = ")" if np.isinf(b) else "]"
|
|
172
|
+
parts.append(f"({_fmt(a, decimals)},{_fmt(b, decimals)}{right}")
|
|
173
|
+
else:
|
|
174
|
+
left = "(" if np.isinf(a) else "["
|
|
175
|
+
parts.append(f"{left}{_fmt(a, decimals)},{_fmt(b, decimals)})")
|
|
176
|
+
else:
|
|
177
|
+
parts.append("NaN" if atom is None else str(atom))
|
|
178
|
+
return "; ".join(parts)
|
tide/_categorical.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Merging strategies for categorical bins: ChiMerge and the TIDE stability merge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy.stats import chi2
|
|
7
|
+
|
|
8
|
+
from ._greedy import EPS
|
|
9
|
+
|
|
10
|
+
__all__ = ["chi2_pvalues", "chi_merge", "tide_merge"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def chi2_pvalues(a0, a1, b0, b1) -> np.ndarray:
|
|
14
|
+
"""Vectorized p-values of the 2x2 chi-squared test with Yates' correction.
|
|
15
|
+
|
|
16
|
+
Matches ``scipy.stats.chi2_contingency([[a0, a1], [b0, b1]]).pvalue``. As in v0.5,
|
|
17
|
+
tables with two or more zero cells are given p = 1, unless the two bins are
|
|
18
|
+
perfectly separated (``[[0, +], [+, 0]]`` or ``[[+, 0], [0, +]]``).
|
|
19
|
+
"""
|
|
20
|
+
obs = np.stack(
|
|
21
|
+
[np.asarray(a0, float), np.asarray(a1, float), np.asarray(b0, float), np.asarray(b1, float)]
|
|
22
|
+
)
|
|
23
|
+
zeros = (obs == 0).sum(axis=0)
|
|
24
|
+
diag = ((obs[0] == 0) & (obs[1] > 0) & (obs[2] > 0) & (obs[3] == 0)) | (
|
|
25
|
+
(obs[0] > 0) & (obs[1] == 0) & (obs[2] == 0) & (obs[3] > 0)
|
|
26
|
+
)
|
|
27
|
+
force_one = (zeros >= 2) & ~diag
|
|
28
|
+
|
|
29
|
+
ra, rb = obs[0] + obs[1], obs[2] + obs[3]
|
|
30
|
+
c0, c1 = obs[0] + obs[2], obs[1] + obs[3]
|
|
31
|
+
tot = ra + rb
|
|
32
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
33
|
+
exp = np.stack([ra * c0, ra * c1, rb * c0, rb * c1]) / tot
|
|
34
|
+
diff = exp - obs
|
|
35
|
+
adj = obs + np.sign(diff) * np.minimum(0.5, np.abs(diff))
|
|
36
|
+
stat = ((adj - exp) ** 2 / exp).sum(axis=0)
|
|
37
|
+
p = chi2.sf(stat, 1)
|
|
38
|
+
p = np.where(force_one, 1.0, p)
|
|
39
|
+
return np.atleast_1d(p)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def chi_merge(n0: np.ndarray, n1: np.ndarray, alpha: float) -> list[list[int]]:
|
|
43
|
+
"""ChiMerge (Kerber, 1992) over categories, as in v0.5.
|
|
44
|
+
|
|
45
|
+
Any two bins may merge. The pair with the highest p-value merges while that
|
|
46
|
+
p-value is above ``alpha``. The merged bin moves to the end of the bin list,
|
|
47
|
+
and ties resolve to the pair first seen (v0.5 order).
|
|
48
|
+
|
|
49
|
+
Returns the groups as lists of category indices, in v0.5 output order.
|
|
50
|
+
"""
|
|
51
|
+
k = n0.shape[0]
|
|
52
|
+
if k < 2:
|
|
53
|
+
return [[i] for i in range(k)]
|
|
54
|
+
members: dict[int, list[int]] = {i: [i] for i in range(k)}
|
|
55
|
+
c0 = np.asarray(n0, float).copy()
|
|
56
|
+
c1 = np.asarray(n1, float).copy()
|
|
57
|
+
order = list(range(k)) # slot ids in bin-list order
|
|
58
|
+
|
|
59
|
+
pv = np.full((k, k), -np.inf)
|
|
60
|
+
rank = np.full((k, k), np.iinfo(np.int64).max, dtype=np.int64)
|
|
61
|
+
iu, ju = np.triu_indices(k, 1)
|
|
62
|
+
pv[iu, ju] = chi2_pvalues(c0[iu], c1[iu], c0[ju], c1[ju])
|
|
63
|
+
rank[iu, ju] = np.arange(iu.size)
|
|
64
|
+
next_rank = iu.size
|
|
65
|
+
|
|
66
|
+
while len(order) > 1:
|
|
67
|
+
best = pv.max()
|
|
68
|
+
if best <= alpha:
|
|
69
|
+
break
|
|
70
|
+
cand = pv == best
|
|
71
|
+
r = np.where(cand, rank, np.iinfo(np.int64).max)
|
|
72
|
+
i, j = np.unravel_index(np.argmin(r), r.shape)
|
|
73
|
+
i, j = int(i), int(j)
|
|
74
|
+
# merge slot j into slot i; the merged bin goes to the end of the list
|
|
75
|
+
members[i] = members[i] + members.pop(j)
|
|
76
|
+
c0[i] += c0[j]
|
|
77
|
+
c1[i] += c1[j]
|
|
78
|
+
order = [s for s in order if s not in (i, j)] + [i]
|
|
79
|
+
for s in (i, j):
|
|
80
|
+
pv[s, :] = -np.inf
|
|
81
|
+
pv[:, s] = -np.inf
|
|
82
|
+
others = np.array(order[:-1], dtype=np.int64)
|
|
83
|
+
if others.size:
|
|
84
|
+
p_new = chi2_pvalues(
|
|
85
|
+
c0[others], c1[others], np.full(others.size, c0[i]), np.full(others.size, c1[i])
|
|
86
|
+
)
|
|
87
|
+
lo = np.minimum(others, i)
|
|
88
|
+
hi = np.maximum(others, i)
|
|
89
|
+
pv[lo, hi] = p_new
|
|
90
|
+
rank[lo, hi] = next_rank + np.arange(others.size)
|
|
91
|
+
next_rank += others.size
|
|
92
|
+
|
|
93
|
+
return [sorted(members[s]) for s in order]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def tide_merge(
|
|
97
|
+
counts: np.ndarray,
|
|
98
|
+
events: np.ndarray,
|
|
99
|
+
n_cont: int,
|
|
100
|
+
trend: str,
|
|
101
|
+
min_sample_rate: float,
|
|
102
|
+
min_class_obs: int,
|
|
103
|
+
min_er_delta: float,
|
|
104
|
+
) -> list[list[int]]:
|
|
105
|
+
"""Merge atomic bins (continuous bins, categories, missing) until all constraints hold.
|
|
106
|
+
|
|
107
|
+
Atoms ``0..n_cont-1`` are the continuous bins, in axis order. Violations are fixed
|
|
108
|
+
in this priority order:
|
|
109
|
+
|
|
110
|
+
0. monotonic event rate of the bins with continuous intervals, in every period;
|
|
111
|
+
1. no crossings of event-rate curves between any two bins (v0.5);
|
|
112
|
+
2. minimum event-rate gap ``min_er_delta`` between any two bins (v0.5);
|
|
113
|
+
3. bin size ``>= min_sample_rate`` in every period (v0.5);
|
|
114
|
+
4. ``>= min_class_obs`` events and non-events in every period (v0.5).
|
|
115
|
+
|
|
116
|
+
For 1-4, the offending bin (the smallest by rows, on ties) merges with the bin
|
|
117
|
+
closest by squared event-rate distance. Two bins with continuous intervals may merge
|
|
118
|
+
only if they are adjacent, so every bin keeps one contiguous interval.
|
|
119
|
+
|
|
120
|
+
Returns the groups as lists of atom indices.
|
|
121
|
+
"""
|
|
122
|
+
pos = trend == "pos"
|
|
123
|
+
c = counts.astype(np.float64).copy()
|
|
124
|
+
e = events.astype(np.float64).copy()
|
|
125
|
+
groups: list[list[int]] = [[a] for a in range(c.shape[0])]
|
|
126
|
+
lo = np.array([a if a < n_cont else -1 for a in range(c.shape[0])], dtype=np.int64)
|
|
127
|
+
hi = lo.copy()
|
|
128
|
+
|
|
129
|
+
def merge(t: int, g: int) -> None:
|
|
130
|
+
nonlocal c, e, lo, hi
|
|
131
|
+
groups[t] = groups[t] + groups[g]
|
|
132
|
+
c[t] += c[g]
|
|
133
|
+
e[t] += e[g]
|
|
134
|
+
if lo[g] >= 0:
|
|
135
|
+
lo[t] = lo[g] if lo[t] < 0 else min(lo[t], lo[g])
|
|
136
|
+
hi[t] = max(hi[t], hi[g])
|
|
137
|
+
del groups[g]
|
|
138
|
+
c = np.delete(c, g, axis=0)
|
|
139
|
+
e = np.delete(e, g, axis=0)
|
|
140
|
+
lo = np.delete(lo, g)
|
|
141
|
+
hi = np.delete(hi, g)
|
|
142
|
+
|
|
143
|
+
while len(groups) > 1:
|
|
144
|
+
er = e / (c + EPS)
|
|
145
|
+
tot = c.sum(axis=1)
|
|
146
|
+
has_cont = lo >= 0
|
|
147
|
+
|
|
148
|
+
# 0. monotonicity of continuous bins
|
|
149
|
+
ci = np.flatnonzero(has_cont)
|
|
150
|
+
if ci.size > 1:
|
|
151
|
+
ci = ci[np.argsort(lo[ci])]
|
|
152
|
+
d = er[ci[1:]] - er[ci[:-1]]
|
|
153
|
+
viol = (d < 0).any(axis=1) if pos else (d > 0).any(axis=1)
|
|
154
|
+
if viol.any():
|
|
155
|
+
cand = np.flatnonzero(viol)
|
|
156
|
+
pair_tot = tot[ci[cand]] + tot[ci[cand + 1]]
|
|
157
|
+
q = cand[np.argmin(pair_tot)]
|
|
158
|
+
t, g = int(ci[q]), int(ci[q + 1])
|
|
159
|
+
merge(t, g)
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
sgn = (er[:, None, :] > er[None, :, :]).astype(np.int8) - (
|
|
163
|
+
er[:, None, :] < er[None, :, :]
|
|
164
|
+
).astype(np.int8)
|
|
165
|
+
crossings = (sgn[:, :, :-1] != sgn[:, :, 1:]).sum(axis=2).sum(axis=1)
|
|
166
|
+
dist = np.abs(er[:, None, :] - er[None, :, :]).min(axis=2)
|
|
167
|
+
np.fill_diagonal(dist, np.inf)
|
|
168
|
+
delta = dist.min(axis=1)
|
|
169
|
+
sample_rate = (c / c.sum(axis=0)).min(axis=1)
|
|
170
|
+
ev_min = e.min(axis=1)
|
|
171
|
+
nev_min = (c - e).min(axis=1)
|
|
172
|
+
|
|
173
|
+
if crossings.max() > 0:
|
|
174
|
+
bad = crossings == crossings.max()
|
|
175
|
+
elif delta.min() < min_er_delta:
|
|
176
|
+
bad = delta == delta.min()
|
|
177
|
+
elif sample_rate.min() < min_sample_rate:
|
|
178
|
+
bad = sample_rate == sample_rate.min()
|
|
179
|
+
elif ev_min.min() < min_class_obs:
|
|
180
|
+
bad = ev_min == ev_min.min()
|
|
181
|
+
elif nev_min.min() < min_class_obs:
|
|
182
|
+
bad = nev_min == nev_min.min()
|
|
183
|
+
else:
|
|
184
|
+
break
|
|
185
|
+
cand = np.flatnonzero(bad)
|
|
186
|
+
t = int(cand[np.argmin(tot[cand])])
|
|
187
|
+
|
|
188
|
+
allowed = np.ones(len(groups), dtype=bool)
|
|
189
|
+
allowed[t] = False
|
|
190
|
+
if has_cont[t]:
|
|
191
|
+
adjacent = (hi[t] + 1 == lo) | (hi + 1 == lo[t])
|
|
192
|
+
allowed &= ~has_cont | adjacent
|
|
193
|
+
if not allowed.any(): # pragma: no cover - cannot happen with >= 2 groups
|
|
194
|
+
break
|
|
195
|
+
sq = ((er[t] - er) ** 2).sum(axis=1)
|
|
196
|
+
sq[~allowed] = np.inf
|
|
197
|
+
g = int(np.argmin(sq))
|
|
198
|
+
merge(t, g)
|
|
199
|
+
|
|
200
|
+
return groups
|