modeltrees 0.1.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.
- modeltrees/__init__.py +74 -0
- modeltrees/_api.py +1351 -0
- modeltrees/_design.py +505 -0
- modeltrees/_formula.py +329 -0
- modeltrees/_frame.py +260 -0
- modeltrees/_hansen_table.py +637 -0
- modeltrees/_loglik.py +37 -0
- modeltrees/_parallel.py +237 -0
- modeltrees/_predict.py +361 -0
- modeltrees/_reference.py +29 -0
- modeltrees/_report.py +393 -0
- modeltrees/_response.py +114 -0
- modeltrees/_vcov.py +223 -0
- modeltrees/errors.py +62 -0
- modeltrees/instability.py +385 -0
- modeltrees/models/__init__.py +38 -0
- modeltrees/models/_common.py +52 -0
- modeltrees/models/_qr.py +232 -0
- modeltrees/models/families.py +423 -0
- modeltrees/models/glm.py +215 -0
- modeltrees/models/glm_fit.py +482 -0
- modeltrees/models/lm.py +236 -0
- modeltrees/models/lm_fit.py +113 -0
- modeltrees/models/multinom.py +453 -0
- modeltrees/models/negbin.py +482 -0
- modeltrees/models/safelogit.py +256 -0
- modeltrees/node_model.py +219 -0
- modeltrees/pruning.py +75 -0
- modeltrees/pvalues.py +215 -0
- modeltrees/py.typed +0 -0
- modeltrees/split_search.py +582 -0
- modeltrees/tree.py +609 -0
- modeltrees-0.1.0.dist-info/METADATA +374 -0
- modeltrees-0.1.0.dist-info/RECORD +37 -0
- modeltrees-0.1.0.dist-info/WHEEL +4 -0
- modeltrees-0.1.0.dist-info/licenses/LICENSE +674 -0
- modeltrees-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +126 -0
modeltrees/_api.py
ADDED
|
@@ -0,0 +1,1351 @@
|
|
|
1
|
+
"""The public tree class and the entry points (docs/api-design.md, sections 1
|
|
2
|
+
and 3).
|
|
3
|
+
|
|
4
|
+
:class:`ModelTree` wraps the core's :class:`~modeltrees.tree.GrownTree`
|
|
5
|
+
and, for the formula entry points, the :class:`~modeltrees._design.Design`
|
|
6
|
+
it was grown from. It is immutable: every view (:class:`NodeInfo`,
|
|
7
|
+
:class:`SplitRule`, :class:`TestResult`) is built from the stored nodes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import warnings as _warnings
|
|
12
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from functools import cached_property
|
|
15
|
+
from typing import Any, Literal, TypedDict, Unpack
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
from numpy.typing import ArrayLike, NDArray
|
|
19
|
+
|
|
20
|
+
from modeltrees._design import (
|
|
21
|
+
ColumnSpec,
|
|
22
|
+
Design,
|
|
23
|
+
NaAction,
|
|
24
|
+
build_design,
|
|
25
|
+
r_double_label,
|
|
26
|
+
)
|
|
27
|
+
from modeltrees._frame import DataInput, Frame, Kind, array_column
|
|
28
|
+
from modeltrees._loglik import LogLik, tree_loglik
|
|
29
|
+
from modeltrees._parallel import resolve_n_jobs
|
|
30
|
+
from modeltrees._predict import (
|
|
31
|
+
PredictType,
|
|
32
|
+
Unseen,
|
|
33
|
+
check_type,
|
|
34
|
+
code_arrays,
|
|
35
|
+
code_newdata,
|
|
36
|
+
predict_leaves,
|
|
37
|
+
raise_unseen,
|
|
38
|
+
route,
|
|
39
|
+
)
|
|
40
|
+
from modeltrees._report import (
|
|
41
|
+
Backend,
|
|
42
|
+
coef_columns,
|
|
43
|
+
format_summary,
|
|
44
|
+
format_tree,
|
|
45
|
+
make_table,
|
|
46
|
+
resolve_backend,
|
|
47
|
+
summary_tables,
|
|
48
|
+
)
|
|
49
|
+
from modeltrees._response import CodedResponse, ResponseRule, code_response
|
|
50
|
+
from modeltrees._vcov import LeafVcov, VcovType, expand, sandwich
|
|
51
|
+
from modeltrees.errors import (
|
|
52
|
+
DataError,
|
|
53
|
+
GlmFitWarning,
|
|
54
|
+
ModelTreesWarning,
|
|
55
|
+
NodeFitFailedWarning,
|
|
56
|
+
TooFewClustersWarning,
|
|
57
|
+
)
|
|
58
|
+
from modeltrees.instability import VariableKind
|
|
59
|
+
from modeltrees.models.glm import GlmModel
|
|
60
|
+
from modeltrees.models.lm import LmModel
|
|
61
|
+
from modeltrees.models.multinom import MultinomModel
|
|
62
|
+
from modeltrees.models.negbin import NegBinModel
|
|
63
|
+
from modeltrees.models.safelogit import SafeLogitModel, diverging_coefficient
|
|
64
|
+
from modeltrees.node_model import FitStatus, NodeModel
|
|
65
|
+
from modeltrees.pruning import PruneRule
|
|
66
|
+
from modeltrees.split_search import Split
|
|
67
|
+
from modeltrees.tree import GrownTree, MobControl, Node
|
|
68
|
+
from modeltrees.tree import mob as _grow
|
|
69
|
+
|
|
70
|
+
# R prints numeric split points rounded to getOption("digits") - 2 = 5
|
|
71
|
+
# decimals (print.partynode, character_split).
|
|
72
|
+
_BREAK_DECIMALS = 5
|
|
73
|
+
# vcov() warns when a node's refit coefficients differ from the stored ones
|
|
74
|
+
# by more than this, relative (docs/api-design.md, section 5.2).
|
|
75
|
+
_REFIT_RTOL = 1e-10
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class RecordedWarning:
|
|
80
|
+
"""A warning emitted while the tree was grown, identical messages merged
|
|
81
|
+
(docs/api-design.md, section 7.3)."""
|
|
82
|
+
|
|
83
|
+
category: type[Warning]
|
|
84
|
+
message: str
|
|
85
|
+
count: int
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class TestResult:
|
|
90
|
+
"""One partitioning variable's instability test at a node: the statistic
|
|
91
|
+
and the adjusted p-value (None where the variable was not tested)."""
|
|
92
|
+
|
|
93
|
+
__test__ = False # not a pytest test class
|
|
94
|
+
|
|
95
|
+
statistic: float
|
|
96
|
+
p_value: float | None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class SplitRule:
|
|
101
|
+
"""A node's binary split in labels.
|
|
102
|
+
|
|
103
|
+
``kind`` is the partitioning variable's kind. Numeric: ``threshold`` is
|
|
104
|
+
the break, ``z <= threshold`` goes left. Ordered: ``threshold`` is the
|
|
105
|
+
last level that goes left. Nominal: ``threshold`` is None. For factors
|
|
106
|
+
``left_levels`` and ``right_levels`` list the levels of each kid, and
|
|
107
|
+
``absent_levels`` the training levels absent from the node, which
|
|
108
|
+
``predict`` sends to the larger kid.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
variable: str
|
|
112
|
+
kind: str
|
|
113
|
+
threshold: float | str | None
|
|
114
|
+
left_levels: tuple[str, ...] | None
|
|
115
|
+
right_levels: tuple[str, ...] | None
|
|
116
|
+
absent_levels: tuple[str, ...] = ()
|
|
117
|
+
boolean: bool = False # a Boolean variable, split into false and true
|
|
118
|
+
|
|
119
|
+
def conditions(self) -> tuple[str, str]:
|
|
120
|
+
"""The readable conditions of the left and the right kid."""
|
|
121
|
+
if self.boolean:
|
|
122
|
+
return f"{self.variable} = false", f"{self.variable} = true"
|
|
123
|
+
if self.kind == "numeric":
|
|
124
|
+
assert isinstance(self.threshold, float)
|
|
125
|
+
label = r_double_label(round(self.threshold, _BREAK_DECIMALS))
|
|
126
|
+
return f"{self.variable} <= {label}", f"{self.variable} > {label}"
|
|
127
|
+
if self.kind == "ordered":
|
|
128
|
+
return (
|
|
129
|
+
f"{self.variable} <= {self.threshold}",
|
|
130
|
+
f"{self.variable} > {self.threshold}",
|
|
131
|
+
)
|
|
132
|
+
assert self.left_levels is not None and self.right_levels is not None
|
|
133
|
+
return (
|
|
134
|
+
f"{self.variable} in {{{', '.join(self.left_levels)}}}",
|
|
135
|
+
f"{self.variable} in {{{', '.join(self.right_levels)}}}",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class NodeInfo:
|
|
141
|
+
"""A frozen view of one node (docs/api-design.md, section 3.2).
|
|
142
|
+
|
|
143
|
+
``core`` is the core :class:`~modeltrees.tree.Node`, for advanced use;
|
|
144
|
+
it is not part of the stable API.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
id: int
|
|
148
|
+
parent: int | None
|
|
149
|
+
kids: tuple[int, ...]
|
|
150
|
+
depth: int
|
|
151
|
+
is_leaf: bool
|
|
152
|
+
n_rows: int
|
|
153
|
+
nobs: float
|
|
154
|
+
rule: tuple[str, ...]
|
|
155
|
+
split: SplitRule | None
|
|
156
|
+
coefficients: dict[str, float]
|
|
157
|
+
objective: float
|
|
158
|
+
status: str
|
|
159
|
+
stop_reason: str
|
|
160
|
+
reason: str | None
|
|
161
|
+
p_value: float | None
|
|
162
|
+
tests: dict[str, TestResult] | None
|
|
163
|
+
extras: dict[str, Any]
|
|
164
|
+
core: Node
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True)
|
|
168
|
+
class TreeSummary:
|
|
169
|
+
"""The tables behind ``summary()`` (docs/api-design.md, section 3.4),
|
|
170
|
+
as frames in the tree's backend, and their printed form.
|
|
171
|
+
|
|
172
|
+
``variables``: each partitioning variable with its kind and levels.
|
|
173
|
+
``nodes``: per node its id, parent, depth, n, nobs, stop reason,
|
|
174
|
+
status, selected variable, statistic, adjusted p-value and split.
|
|
175
|
+
``tests``: the full instability-test table of every tested node.
|
|
176
|
+
``coefficients``: the leaf coefficients and model reports, as
|
|
177
|
+
``coef_table()``. ``dropped_levels`` and ``missing_counts`` describe
|
|
178
|
+
the missing-data removal, ``dropped_rows`` counts the rows it dropped.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
variables: Any
|
|
182
|
+
nodes: Any
|
|
183
|
+
tests: Any
|
|
184
|
+
coefficients: Any
|
|
185
|
+
dropped_levels: dict[str, tuple[str, ...]]
|
|
186
|
+
dropped_rows: int
|
|
187
|
+
missing_counts: dict[str, int]
|
|
188
|
+
warnings: tuple[RecordedWarning, ...]
|
|
189
|
+
text: str
|
|
190
|
+
|
|
191
|
+
def __str__(self) -> str:
|
|
192
|
+
return self.text
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass(frozen=True)
|
|
196
|
+
class _VariableInfo:
|
|
197
|
+
"""A partitioning variable's metadata: its kind and levels as the tree
|
|
198
|
+
used them, and whether it was a Boolean column (numeric 0/1)."""
|
|
199
|
+
|
|
200
|
+
name: str
|
|
201
|
+
kind: VariableKind
|
|
202
|
+
levels: tuple[str, ...] | None
|
|
203
|
+
boolean: bool = False
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _readonly(array: NDArray[Any]) -> NDArray[Any]:
|
|
207
|
+
out = np.array(array, copy=True)
|
|
208
|
+
out.setflags(write=False)
|
|
209
|
+
return out
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class ModelTree:
|
|
213
|
+
"""A fitted model-based tree (docs/api-design.md, section 3.1).
|
|
214
|
+
|
|
215
|
+
Returned by every entry point (:func:`lmtree`, :func:`glmtree`,
|
|
216
|
+
:func:`negbintree`, :func:`safelogittree`, :func:`multinomtree`) and by
|
|
217
|
+
:func:`mob`. Immutable after construction.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
def __init__(
|
|
221
|
+
self,
|
|
222
|
+
grown: GrownTree,
|
|
223
|
+
model: NodeModel,
|
|
224
|
+
*,
|
|
225
|
+
model_name: str,
|
|
226
|
+
formula: str | None,
|
|
227
|
+
design: Design | None,
|
|
228
|
+
response_levels: tuple[str, ...] | None,
|
|
229
|
+
warnings: tuple[RecordedWarning, ...],
|
|
230
|
+
used_rows: NDArray[np.bool_],
|
|
231
|
+
x: NDArray[np.float64] | None,
|
|
232
|
+
offset: NDArray[np.float64] | None,
|
|
233
|
+
boolean: frozenset[str] = frozenset(),
|
|
234
|
+
backend: str | None = None,
|
|
235
|
+
y: NDArray[Any] | None = None,
|
|
236
|
+
weights: NDArray[np.float64] | None = None,
|
|
237
|
+
cluster: NDArray[Any] | None = None,
|
|
238
|
+
frame: Frame | None = None,
|
|
239
|
+
) -> None:
|
|
240
|
+
self._grown = grown
|
|
241
|
+
self._model = model
|
|
242
|
+
self._model_name = model_name
|
|
243
|
+
self._formula = formula
|
|
244
|
+
self._design = design
|
|
245
|
+
self._response_levels = response_levels
|
|
246
|
+
self._warnings = warnings
|
|
247
|
+
self._used_rows = _readonly(np.asarray(used_rows, dtype=np.bool_))
|
|
248
|
+
fitted = np.zeros(self._used_rows.shape[0], dtype=np.int64)
|
|
249
|
+
fitted[self._used_rows] = grown.fitted_node
|
|
250
|
+
self._fitted_node = _readonly(fitted)
|
|
251
|
+
self._x = x
|
|
252
|
+
self._offset = offset
|
|
253
|
+
self._variables = tuple(
|
|
254
|
+
_VariableInfo(v.name, v.kind, v.levels, v.name in boolean)
|
|
255
|
+
for v in grown.variables
|
|
256
|
+
)
|
|
257
|
+
self._backend = backend
|
|
258
|
+
# What vcov() refits from, over the used rows: the coded response,
|
|
259
|
+
# the weights and the tree's clusters; and the training data, for a
|
|
260
|
+
# cluster column given by name.
|
|
261
|
+
self._y = y
|
|
262
|
+
self._weights = weights
|
|
263
|
+
self._cluster = cluster
|
|
264
|
+
self._frame = frame
|
|
265
|
+
self._frozen = True
|
|
266
|
+
|
|
267
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
268
|
+
if getattr(self, "_frozen", False):
|
|
269
|
+
raise AttributeError("ModelTree is immutable")
|
|
270
|
+
object.__setattr__(self, name, value)
|
|
271
|
+
|
|
272
|
+
# Settings and data
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def formula(self) -> str | None:
|
|
276
|
+
"""The expanded formula, e.g. ``"y ~ 1 | z1 + z2"``; None from mob()."""
|
|
277
|
+
return self._formula
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def model_name(self) -> str:
|
|
281
|
+
"""``"lmtree"``, ``"glmtree(binomial)"``, ``"negbintree"``, ..."""
|
|
282
|
+
return self._model_name
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def control(self) -> MobControl:
|
|
286
|
+
return self._grown.control
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def model(self) -> NodeModel:
|
|
290
|
+
"""The node model the tree was grown with."""
|
|
291
|
+
return self._model
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def n_rows(self) -> int:
|
|
295
|
+
"""Rows in the input data."""
|
|
296
|
+
return int(self._used_rows.shape[0])
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def n_used(self) -> int:
|
|
300
|
+
"""Rows left after missing-data removal."""
|
|
301
|
+
return int(self._used_rows.sum())
|
|
302
|
+
|
|
303
|
+
@property
|
|
304
|
+
def used_rows(self) -> NDArray[np.bool_]:
|
|
305
|
+
"""Mask over the input rows: True for the rows the tree used."""
|
|
306
|
+
return self._used_rows
|
|
307
|
+
|
|
308
|
+
@property
|
|
309
|
+
def fitted_node(self) -> NDArray[np.int64]:
|
|
310
|
+
"""The leaf id of each input row, 0 for rows dropped for missing values."""
|
|
311
|
+
return self._fitted_node
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def response_levels(self) -> tuple[str, ...] | None:
|
|
315
|
+
"""The level order of a categorical response (binomial: the first
|
|
316
|
+
level is failure; multinomial: the first is the reference), else None."""
|
|
317
|
+
return self._response_levels
|
|
318
|
+
|
|
319
|
+
@property
|
|
320
|
+
def warnings(self) -> tuple[RecordedWarning, ...]:
|
|
321
|
+
"""The warnings emitted while the tree was grown, merged."""
|
|
322
|
+
return self._warnings
|
|
323
|
+
|
|
324
|
+
# Nodes
|
|
325
|
+
|
|
326
|
+
@cached_property
|
|
327
|
+
def _node_infos(self) -> tuple[NodeInfo, ...]:
|
|
328
|
+
infos: list[NodeInfo] = []
|
|
329
|
+
self._collect(self._grown.root, None, (), infos)
|
|
330
|
+
infos.sort(key=lambda info: info.id)
|
|
331
|
+
return tuple(infos)
|
|
332
|
+
|
|
333
|
+
def _collect(
|
|
334
|
+
self,
|
|
335
|
+
node: Node,
|
|
336
|
+
parent: int | None,
|
|
337
|
+
rule: tuple[str, ...],
|
|
338
|
+
out: list[NodeInfo],
|
|
339
|
+
) -> None:
|
|
340
|
+
split = None if node.split is None else self._split_rule(node)
|
|
341
|
+
out.append(self._node_info(node, parent, rule, split))
|
|
342
|
+
if split is not None:
|
|
343
|
+
for kid, condition in zip(node.kids, split.conditions(), strict=True):
|
|
344
|
+
self._collect(kid, node.id, (*rule, condition), out)
|
|
345
|
+
|
|
346
|
+
def _split_rule(self, node: Node) -> SplitRule:
|
|
347
|
+
split: Split | None = node.split
|
|
348
|
+
assert split is not None
|
|
349
|
+
var = self._variables[split.variable]
|
|
350
|
+
if split.kind is VariableKind.NUMERIC:
|
|
351
|
+
assert split.breaks is not None
|
|
352
|
+
return SplitRule(
|
|
353
|
+
var.name, "numeric", float(split.breaks), None, None, (), var.boolean
|
|
354
|
+
)
|
|
355
|
+
assert var.levels is not None
|
|
356
|
+
if split.kind is VariableKind.ORDERED:
|
|
357
|
+
assert split.breaks is not None
|
|
358
|
+
b = int(split.breaks)
|
|
359
|
+
return SplitRule(
|
|
360
|
+
var.name, "ordered", var.levels[b - 1], var.levels[:b], var.levels[b:]
|
|
361
|
+
)
|
|
362
|
+
assert split.index is not None
|
|
363
|
+
sides: dict[int | None, list[str]] = {1: [], 2: [], None: []}
|
|
364
|
+
for level, kid in zip(var.levels, split.index, strict=True):
|
|
365
|
+
sides[kid].append(level)
|
|
366
|
+
return SplitRule(
|
|
367
|
+
var.name,
|
|
368
|
+
"nominal",
|
|
369
|
+
None,
|
|
370
|
+
tuple(sides[1]),
|
|
371
|
+
tuple(sides[2]),
|
|
372
|
+
tuple(sides[None]),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
def _node_info(
|
|
376
|
+
self,
|
|
377
|
+
node: Node,
|
|
378
|
+
parent: int | None,
|
|
379
|
+
rule: tuple[str, ...],
|
|
380
|
+
split: SplitRule | None,
|
|
381
|
+
) -> NodeInfo:
|
|
382
|
+
fit = node.fit
|
|
383
|
+
tests = None
|
|
384
|
+
if node.test is not None:
|
|
385
|
+
tests = {
|
|
386
|
+
name: TestResult(float(stat), None if math.isnan(p) else float(p))
|
|
387
|
+
for name, stat, p in zip(
|
|
388
|
+
node.test.variables,
|
|
389
|
+
node.test.statistic,
|
|
390
|
+
node.test.p_value_adjusted,
|
|
391
|
+
strict=True,
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
return NodeInfo(
|
|
395
|
+
id=node.id,
|
|
396
|
+
parent=parent,
|
|
397
|
+
kids=tuple(kid.id for kid in node.kids),
|
|
398
|
+
depth=node.depth,
|
|
399
|
+
is_leaf=node.terminal,
|
|
400
|
+
n_rows=int(node.rows.shape[0]),
|
|
401
|
+
nobs=float(node.nobs),
|
|
402
|
+
rule=rule,
|
|
403
|
+
split=split,
|
|
404
|
+
coefficients={
|
|
405
|
+
name: float(c)
|
|
406
|
+
for name, c in zip(fit.coef_names, fit.coefficients, strict=True)
|
|
407
|
+
},
|
|
408
|
+
objective=float(fit.objfun),
|
|
409
|
+
status=fit.status.value,
|
|
410
|
+
stop_reason=node.stop_reason.value,
|
|
411
|
+
reason=fit.reason,
|
|
412
|
+
p_value=node.p_value,
|
|
413
|
+
tests=tests,
|
|
414
|
+
extras=self._extras(node),
|
|
415
|
+
core=node,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
def _extras(self, node: Node) -> dict[str, Any]:
|
|
419
|
+
"""The model report (section 3.5), plus the fit's warning texts."""
|
|
420
|
+
report = getattr(self._model, "report", None)
|
|
421
|
+
extras: dict[str, Any] = dict(report(node.fit)) if callable(report) else {}
|
|
422
|
+
messages = node.fit.extras.get("warnings")
|
|
423
|
+
if messages is not None:
|
|
424
|
+
extras["warnings"] = tuple(messages)
|
|
425
|
+
return extras
|
|
426
|
+
|
|
427
|
+
def node(self, node_id: int) -> NodeInfo:
|
|
428
|
+
"""The node with id ``node_id`` (1-based, preorder, as partykit)."""
|
|
429
|
+
infos = self._node_infos
|
|
430
|
+
if not isinstance(node_id, (int, np.integer)) or not (
|
|
431
|
+
1 <= node_id <= len(infos)
|
|
432
|
+
):
|
|
433
|
+
raise KeyError(f"no node {node_id!r}; ids are 1 to {len(infos)}")
|
|
434
|
+
return infos[int(node_id) - 1]
|
|
435
|
+
|
|
436
|
+
@property
|
|
437
|
+
def nodes(self) -> tuple[NodeInfo, ...]:
|
|
438
|
+
"""Every node, in id (preorder) order."""
|
|
439
|
+
return self._node_infos
|
|
440
|
+
|
|
441
|
+
@property
|
|
442
|
+
def leaves(self) -> tuple[int, ...]:
|
|
443
|
+
"""The ids of the terminal nodes."""
|
|
444
|
+
return tuple(info.id for info in self._node_infos if info.is_leaf)
|
|
445
|
+
|
|
446
|
+
@property
|
|
447
|
+
def depth(self) -> int:
|
|
448
|
+
"""The largest node depth, the root at depth 1 (so ``maxdepth``
|
|
449
|
+
bounds it; partykit's ``depth()`` is one less)."""
|
|
450
|
+
return max(info.depth for info in self._node_infos)
|
|
451
|
+
|
|
452
|
+
# Prediction
|
|
453
|
+
|
|
454
|
+
def predict(
|
|
455
|
+
self,
|
|
456
|
+
newdata: DataInput | None = None,
|
|
457
|
+
*,
|
|
458
|
+
type: PredictType = "response",
|
|
459
|
+
offset: ColumnSpec = None,
|
|
460
|
+
unseen: Unseen = "error",
|
|
461
|
+
x: ArrayLike | None = None,
|
|
462
|
+
z: Mapping[str, ArrayLike] | None = None,
|
|
463
|
+
) -> NDArray[Any]:
|
|
464
|
+
"""Predictions as a numpy array (docs/api-design.md, section 4.2).
|
|
465
|
+
|
|
466
|
+
``type``: "node" (the leaf id per row, int64, 0 for no node),
|
|
467
|
+
"link" (the linear predictor), "response" (its inverse link),
|
|
468
|
+
"prob" and "class" (multinomial: the probability matrix with
|
|
469
|
+
columns in ``response_levels`` order, and the most probable label
|
|
470
|
+
as an object array). For a multinomial tree "link" is the
|
|
471
|
+
``(n, K - 1)`` matrix of the non-reference classes and "response"
|
|
472
|
+
is undefined. Rows without a prediction are NaN (None for
|
|
473
|
+
"class").
|
|
474
|
+
|
|
475
|
+
``newdata`` is coded with the training levels; None predicts the
|
|
476
|
+
training rows (rows dropped for missing values get none). A row
|
|
477
|
+
stops at the first split whose variable it lacks (node 0). A level
|
|
478
|
+
absent from the node at a split goes to the larger kid. A level
|
|
479
|
+
never seen in training raises :class:`UnseenLevelError`, or with
|
|
480
|
+
``unseen="null"`` gives the row no prediction. ``offset`` (a column
|
|
481
|
+
name or an array) overrides the offset of new data, which is
|
|
482
|
+
otherwise read from the training offset's column. Trees grown by
|
|
483
|
+
:func:`mob` take ``z`` (and ``x``, ``offset``) instead of
|
|
484
|
+
``newdata``.
|
|
485
|
+
"""
|
|
486
|
+
check_type(type, unseen)
|
|
487
|
+
needs_x = type != "node"
|
|
488
|
+
if self._design is None:
|
|
489
|
+
if newdata is not None:
|
|
490
|
+
raise TypeError(
|
|
491
|
+
"a tree grown by mob() predicts from x and z, not newdata"
|
|
492
|
+
)
|
|
493
|
+
if z is None and (x is not None or offset is not None):
|
|
494
|
+
raise TypeError("pass z, the partitioning variables, with x or offset")
|
|
495
|
+
coded = None
|
|
496
|
+
if z is not None:
|
|
497
|
+
if offset is not None and isinstance(offset, str):
|
|
498
|
+
raise TypeError("offset must be an array for a tree grown by mob()")
|
|
499
|
+
coded = code_arrays(
|
|
500
|
+
self._grown.variables,
|
|
501
|
+
z,
|
|
502
|
+
x,
|
|
503
|
+
offset,
|
|
504
|
+
has_x=self._x is not None,
|
|
505
|
+
has_offset=self._offset is not None,
|
|
506
|
+
needs_x=needs_x,
|
|
507
|
+
)
|
|
508
|
+
else:
|
|
509
|
+
if x is not None or z is not None:
|
|
510
|
+
raise TypeError("x and z are for trees grown by mob(); pass newdata")
|
|
511
|
+
if newdata is None and offset is not None:
|
|
512
|
+
raise TypeError("offset applies to newdata; pass the data as newdata")
|
|
513
|
+
coded = (
|
|
514
|
+
None
|
|
515
|
+
if newdata is None
|
|
516
|
+
else code_newdata(self._design, newdata, offset=offset, needs_x=needs_x)
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
if coded is None:
|
|
520
|
+
# The training rows.
|
|
521
|
+
nodes = np.array(self._fitted_node)
|
|
522
|
+
no_prediction = ~self._used_rows
|
|
523
|
+
xm, off = self._training_x() if needs_x else (None, None)
|
|
524
|
+
else:
|
|
525
|
+
if coded.unseen.any() and unseen == "error":
|
|
526
|
+
raise_unseen(coded.unseen_levels)
|
|
527
|
+
nodes = route(self._grown.root, coded.z, coded.n)
|
|
528
|
+
nodes[coded.unseen] = 0
|
|
529
|
+
no_prediction = coded.x_missing | coded.unseen
|
|
530
|
+
xm, off = coded.x, coded.offset
|
|
531
|
+
if type == "node":
|
|
532
|
+
return nodes
|
|
533
|
+
assert xm is not None
|
|
534
|
+
leaves = {node.id: node for node in self._grown.root.walk() if node.terminal}
|
|
535
|
+
kind = type
|
|
536
|
+
values = predict_leaves(
|
|
537
|
+
self._model,
|
|
538
|
+
leaves,
|
|
539
|
+
nodes,
|
|
540
|
+
xm,
|
|
541
|
+
off,
|
|
542
|
+
no_prediction | (nodes == 0),
|
|
543
|
+
kind,
|
|
544
|
+
self._grown.root,
|
|
545
|
+
)
|
|
546
|
+
if type != "class":
|
|
547
|
+
return values
|
|
548
|
+
levels = self._response_levels or tuple(
|
|
549
|
+
str(v) for v in getattr(self._model, "levels", ())
|
|
550
|
+
)
|
|
551
|
+
labels = np.full(values.shape[0], None, dtype=object)
|
|
552
|
+
defined = ~np.isnan(values)
|
|
553
|
+
labels[defined] = [levels[int(c)] for c in values[defined]]
|
|
554
|
+
return labels
|
|
555
|
+
|
|
556
|
+
def _training_x(self) -> tuple[NDArray[np.float64], NDArray[np.float64] | None]:
|
|
557
|
+
"""The training regressors and offset over the input rows, NaN for
|
|
558
|
+
dropped rows (an intercept column when the model had none)."""
|
|
559
|
+
used = self._used_rows
|
|
560
|
+
x = self._x if self._x is not None else np.ones((int(used.sum()), 1))
|
|
561
|
+
xm = np.full((used.shape[0], x.shape[1]), np.nan)
|
|
562
|
+
xm[used] = x
|
|
563
|
+
off = None
|
|
564
|
+
if self._offset is not None:
|
|
565
|
+
off = np.full(used.shape[0], np.nan)
|
|
566
|
+
off[used] = self._offset
|
|
567
|
+
return xm, off
|
|
568
|
+
|
|
569
|
+
# Tables, likelihood and printing
|
|
570
|
+
|
|
571
|
+
def coef_table(
|
|
572
|
+
self,
|
|
573
|
+
*,
|
|
574
|
+
layout: Literal["wide", "long"] = "wide",
|
|
575
|
+
se: Literal["robust"] | None = None,
|
|
576
|
+
backend: Backend = None,
|
|
577
|
+
) -> Any:
|
|
578
|
+
"""The leaf coefficients, one row per leaf (section 3.3).
|
|
579
|
+
|
|
580
|
+
``layout="wide"``: ``node``, ``n_rows``, ``nobs``, ``status``,
|
|
581
|
+
``stop_reason``, one column per coefficient (NaN for aliased or
|
|
582
|
+
unidentified ones), with ``se="robust"`` one ``std_error_<term>``
|
|
583
|
+
column per coefficient, then the model's report columns.
|
|
584
|
+
``layout="long"``: ``node``, ``term``, ``estimate``, and with
|
|
585
|
+
``se="robust"`` ``std_error``. The standard errors are those of
|
|
586
|
+
:meth:`vcov` with its defaults (the tree's clusters, HC1 with
|
|
587
|
+
cadjust), NaN where the covariance is not "ok". The frame is in the
|
|
588
|
+
backend of the data the tree was grown from, polars for arrays (a
|
|
589
|
+
dict of numpy arrays without polars), or ``backend``: "polars",
|
|
590
|
+
"pandas", "pyarrow" or "numpy".
|
|
591
|
+
|
|
592
|
+
No p-values or confidence intervals are given: the leaves were
|
|
593
|
+
selected by tests on the same data, so standard errors are
|
|
594
|
+
conditional on the tree and too small for inference about the
|
|
595
|
+
subgroups (section 5.4). Grow the tree on one part of the data and
|
|
596
|
+
estimate on the other for honest inference.
|
|
597
|
+
"""
|
|
598
|
+
if se not in (None, "robust"):
|
|
599
|
+
raise ValueError(f"se must be None or 'robust', got {se!r}")
|
|
600
|
+
if layout not in ("wide", "long"):
|
|
601
|
+
raise ValueError(f"layout must be 'wide' or 'long', got {layout!r}")
|
|
602
|
+
std_errors = None
|
|
603
|
+
if se == "robust":
|
|
604
|
+
std_errors = {
|
|
605
|
+
i: np.sqrt(np.diag(v.matrix))
|
|
606
|
+
for i, v in self._vcov(None, None, "HC1", True, stacklevel=4).items()
|
|
607
|
+
}
|
|
608
|
+
return make_table(
|
|
609
|
+
coef_columns(self, layout, std_errors),
|
|
610
|
+
resolve_backend(backend, self._backend),
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
def vcov(
|
|
614
|
+
self,
|
|
615
|
+
node_id: int | None = None,
|
|
616
|
+
*,
|
|
617
|
+
cluster: str | ArrayLike | bool | None = None,
|
|
618
|
+
type: VcovType = "HC1",
|
|
619
|
+
cadjust: bool = True,
|
|
620
|
+
) -> dict[int, LeafVcov]:
|
|
621
|
+
"""Cluster-robust covariances of node coefficients (section 5).
|
|
622
|
+
|
|
623
|
+
``V = H^-1 (c sum_g s_g s_g') H^-1``, with ``H`` the node model's
|
|
624
|
+
Hessian and ``s_g`` its scores summed within cluster ``g``, from a
|
|
625
|
+
refit of each requested node on its rows. ``c`` is ``G / (G - 1)``
|
|
626
|
+
with ``cadjust`` and also ``(n - 1) / (n - k)`` with ``type="HC1"``,
|
|
627
|
+
G the clusters with positive weight in the node, n its ``nobs`` and
|
|
628
|
+
k its estimated coefficients. Without clusters each row is its own
|
|
629
|
+
cluster; under case weights that is the sandwich of the data with
|
|
630
|
+
each row repeated ``w_i`` times.
|
|
631
|
+
|
|
632
|
+
``node_id`` None gives every leaf; an inner node is allowed too.
|
|
633
|
+
``cluster`` None uses the tree's clusters if it was grown with them,
|
|
634
|
+
else none; False forces no clusters; True requires the tree's; a
|
|
635
|
+
column name (of the training data) or an array over the input rows
|
|
636
|
+
gives other clusters, for example a coarser level than the tests
|
|
637
|
+
used. They must not be missing on the rows the tree used.
|
|
638
|
+
|
|
639
|
+
The default, HC1 with ``cadjust``, is the same for every model and
|
|
640
|
+
equals statsmodels' ``cov_type="cluster"``. R's
|
|
641
|
+
``sandwich::vcovCL`` defaults to HC1 only for ``lm`` fits and to HC0
|
|
642
|
+
for every other model, glm included: to compare a glmtree,
|
|
643
|
+
negbintree, safelogittree or multinomtree leaf with R, pass
|
|
644
|
+
``type="HC0"``.
|
|
645
|
+
|
|
646
|
+
Each :class:`LeafVcov` has status "ok", "singular" (the Hessian
|
|
647
|
+
scaled by its diagonal is not positive definite, or has a condition
|
|
648
|
+
number above 1 / eps) or "unavailable" (the fit failed or has no
|
|
649
|
+
scores, as a completely separated safe logit or a multinomial leaf
|
|
650
|
+
without the reference class, or a single cluster). With ``G <= k``
|
|
651
|
+
clusters the meat has rank below k, under any case weights, and a
|
|
652
|
+
:class:`TooFewClustersWarning` names the node. Negative binomial
|
|
653
|
+
covariances are conditional on theta, as ``vcov(glm.nb)``.
|
|
654
|
+
|
|
655
|
+
The leaves were selected by tests on the same data, so these
|
|
656
|
+
covariances are conditional on the tree and too small for
|
|
657
|
+
inference about the subgroups (section 5.4); grow on one part of
|
|
658
|
+
the data and estimate on the other for honest inference.
|
|
659
|
+
"""
|
|
660
|
+
if type not in ("HC0", "HC1"):
|
|
661
|
+
raise ValueError(f"type must be 'HC0' or 'HC1', got {type!r}")
|
|
662
|
+
return self._vcov(node_id, cluster, type, bool(cadjust), stacklevel=3)
|
|
663
|
+
|
|
664
|
+
def _vcov(
|
|
665
|
+
self,
|
|
666
|
+
node_id: int | None,
|
|
667
|
+
cluster: str | ArrayLike | bool | None,
|
|
668
|
+
type: VcovType,
|
|
669
|
+
cadjust: bool,
|
|
670
|
+
*,
|
|
671
|
+
stacklevel: int,
|
|
672
|
+
) -> dict[int, LeafVcov]:
|
|
673
|
+
if self._y is None or self._weights is None:
|
|
674
|
+
raise TypeError("this tree keeps no training data to refit its nodes")
|
|
675
|
+
ids = self.leaves if node_id is None else (self.node(node_id).id,)
|
|
676
|
+
clusters = self._vcov_cluster(cluster)
|
|
677
|
+
return {
|
|
678
|
+
i: self._node_vcov(i, clusters, type, cadjust, stacklevel + 1) for i in ids
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
def _vcov_cluster(
|
|
682
|
+
self, cluster: str | ArrayLike | bool | None
|
|
683
|
+
) -> NDArray[Any] | None:
|
|
684
|
+
"""The clusters over the used rows, or None (section 5.1)."""
|
|
685
|
+
if cluster is None:
|
|
686
|
+
return self._cluster
|
|
687
|
+
if cluster is False:
|
|
688
|
+
return None
|
|
689
|
+
if cluster is True:
|
|
690
|
+
if self._cluster is None:
|
|
691
|
+
raise ValueError(
|
|
692
|
+
"cluster=True, but the tree was grown without clusters"
|
|
693
|
+
)
|
|
694
|
+
return self._cluster
|
|
695
|
+
if isinstance(cluster, str):
|
|
696
|
+
if self._frame is None:
|
|
697
|
+
raise TypeError(
|
|
698
|
+
"a tree grown by mob() takes other clusters as an array, "
|
|
699
|
+
"not a column name"
|
|
700
|
+
)
|
|
701
|
+
column = self._frame.column(cluster)
|
|
702
|
+
name = repr(cluster)
|
|
703
|
+
else:
|
|
704
|
+
column = array_column("(cluster)", cluster)
|
|
705
|
+
name = "(cluster)"
|
|
706
|
+
if column.values.shape[0] != self.n_rows:
|
|
707
|
+
raise DataError(
|
|
708
|
+
f"cluster has {column.values.shape[0]} values but the data has "
|
|
709
|
+
f"{self.n_rows} rows"
|
|
710
|
+
)
|
|
711
|
+
missing = column.missing & self._used_rows
|
|
712
|
+
if missing.any():
|
|
713
|
+
raise DataError(
|
|
714
|
+
f"cluster {name} is missing on {int(missing.sum())} of the rows "
|
|
715
|
+
"the tree used"
|
|
716
|
+
)
|
|
717
|
+
return column.values[self._used_rows]
|
|
718
|
+
|
|
719
|
+
def _node_vcov(
|
|
720
|
+
self,
|
|
721
|
+
node_id: int,
|
|
722
|
+
clusters: NDArray[Any] | None,
|
|
723
|
+
type: VcovType,
|
|
724
|
+
cadjust: bool,
|
|
725
|
+
stacklevel: int,
|
|
726
|
+
) -> LeafVcov:
|
|
727
|
+
node = self._grown.node(node_id)
|
|
728
|
+
assert self._y is not None and self._weights is not None
|
|
729
|
+
rows = node.rows
|
|
730
|
+
weights = self._weights[rows]
|
|
731
|
+
with _warnings.catch_warnings():
|
|
732
|
+
# The node's fit warned while the tree was grown, and the
|
|
733
|
+
# warnings were recorded then.
|
|
734
|
+
_warnings.simplefilter("ignore", GlmFitWarning)
|
|
735
|
+
fit = self._model.fit(
|
|
736
|
+
self._y[rows],
|
|
737
|
+
None if self._x is None else self._x[rows],
|
|
738
|
+
start=None,
|
|
739
|
+
weights=weights,
|
|
740
|
+
offset=None if self._offset is None else self._offset[rows],
|
|
741
|
+
cluster=None if self._cluster is None else self._cluster[rows],
|
|
742
|
+
caseweights=self.control.caseweights,
|
|
743
|
+
estfun=True,
|
|
744
|
+
vcov=True,
|
|
745
|
+
)
|
|
746
|
+
stored = node.fit.coefficients
|
|
747
|
+
got = fit.coefficients
|
|
748
|
+
same_nan = np.array_equal(np.isnan(stored), np.isnan(got))
|
|
749
|
+
finite = ~np.isnan(stored) & ~np.isnan(got)
|
|
750
|
+
with np.errstate(invalid="ignore"):
|
|
751
|
+
diff = np.abs(got[finite] - stored[finite])
|
|
752
|
+
scale = np.maximum(np.abs(got[finite]), np.abs(stored[finite]))
|
|
753
|
+
if not same_nan or np.any(diff > _REFIT_RTOL * scale):
|
|
754
|
+
rel = float(np.max(diff / np.where(scale > 0, scale, 1.0), initial=0.0))
|
|
755
|
+
detail = "NaN in other places" if not same_nan else f"{rel:.1e} relative"
|
|
756
|
+
_warnings.warn(
|
|
757
|
+
f"the refit of node {node_id} for vcov() differs from its stored "
|
|
758
|
+
f"coefficients ({detail}); the covariance is the refit's",
|
|
759
|
+
ModelTreesWarning,
|
|
760
|
+
stacklevel=stacklevel + 1,
|
|
761
|
+
)
|
|
762
|
+
result = sandwich(
|
|
763
|
+
fit.hessian,
|
|
764
|
+
fit.estfun,
|
|
765
|
+
weights=weights,
|
|
766
|
+
cluster=None if clusters is None else clusters[rows],
|
|
767
|
+
caseweights=self.control.caseweights,
|
|
768
|
+
type=type,
|
|
769
|
+
cadjust=cadjust,
|
|
770
|
+
)
|
|
771
|
+
reason = result.reason
|
|
772
|
+
if fit.status is not FitStatus.OK:
|
|
773
|
+
reason = f"the fit has status {fit.status.value}: {fit.reason}"
|
|
774
|
+
elif result.too_few_clusters and clusters is not None:
|
|
775
|
+
k = int(np.sum(~np.isnan(got)))
|
|
776
|
+
_warnings.warn(
|
|
777
|
+
f"node {node_id} has {result.n_clusters:g} clusters for {k} "
|
|
778
|
+
"coefficients, so its cluster-robust covariance has rank below "
|
|
779
|
+
f"{k} and is unreliable",
|
|
780
|
+
TooFewClustersWarning,
|
|
781
|
+
stacklevel=stacklevel + 1,
|
|
782
|
+
)
|
|
783
|
+
if (
|
|
784
|
+
fit.status is FitStatus.OK
|
|
785
|
+
and fit.extras.get("separation") == "quasi"
|
|
786
|
+
and fit.hessian is not None
|
|
787
|
+
):
|
|
788
|
+
estimated = ~np.isnan(got)
|
|
789
|
+
j = diverging_coefficient(
|
|
790
|
+
fit.hessian,
|
|
791
|
+
None if self._x is None else self._x[rows][:, estimated],
|
|
792
|
+
weights,
|
|
793
|
+
)
|
|
794
|
+
term = [n for n, e in zip(fit.coef_names, estimated, strict=True) if e][j]
|
|
795
|
+
# Quasi-separation need not make the scaled Hessian ill
|
|
796
|
+
# conditioned, so the status can stay "ok" (docs/api-port.md,
|
|
797
|
+
# section 15.1, item 6); the reason says which term diverges.
|
|
798
|
+
quasi = (
|
|
799
|
+
f"node {node_id} is quasi-separated: coefficient {term!r} "
|
|
800
|
+
"diverges, so its variance is not reliable"
|
|
801
|
+
)
|
|
802
|
+
_warnings.warn(quasi, ModelTreesWarning, stacklevel=stacklevel + 1)
|
|
803
|
+
reason = quasi if reason is None else f"{reason}; {quasi}"
|
|
804
|
+
return LeafVcov(
|
|
805
|
+
node=node_id,
|
|
806
|
+
names=fit.coef_names,
|
|
807
|
+
matrix=expand(result, ~np.isnan(got)),
|
|
808
|
+
n_clusters=None
|
|
809
|
+
if clusters is None
|
|
810
|
+
else int(np.unique(clusters[rows][weights > 0]).shape[0]),
|
|
811
|
+
status=result.status,
|
|
812
|
+
reason=reason,
|
|
813
|
+
condition=result.condition,
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
def loglik(self) -> LogLik:
|
|
817
|
+
"""The tree's log-likelihood, as ``logLik.modelparty``: the sum over
|
|
818
|
+
the leaves, df the leaf df plus ``dfsplit`` per inner node, nobs
|
|
819
|
+
the summed leaf ``nobs`` (section 3.6). NaN, with a
|
|
820
|
+
:class:`NodeFitFailedWarning`, if a leaf fit failed."""
|
|
821
|
+
if not callable(getattr(self._model, "loglik", None)):
|
|
822
|
+
raise TypeError(
|
|
823
|
+
f"{type(self._model).__name__} has no loglik method "
|
|
824
|
+
"(the LogLikModel protocol)"
|
|
825
|
+
)
|
|
826
|
+
model: Any = self._model
|
|
827
|
+
failed = [i.id for i in self.nodes if i.is_leaf and i.status == "failed"]
|
|
828
|
+
if failed:
|
|
829
|
+
_warnings.warn(
|
|
830
|
+
f"the fit of leaf {', '.join(map(str, failed))} failed, so the "
|
|
831
|
+
"tree's log-likelihood is undefined (NaN)",
|
|
832
|
+
NodeFitFailedWarning,
|
|
833
|
+
stacklevel=3,
|
|
834
|
+
)
|
|
835
|
+
return tree_loglik(self._grown, model)
|
|
836
|
+
|
|
837
|
+
def aic(self) -> float:
|
|
838
|
+
"""``-2 loglik + 2 df``."""
|
|
839
|
+
ll = self.loglik()
|
|
840
|
+
return -2 * ll.value + 2 * ll.df
|
|
841
|
+
|
|
842
|
+
def bic(self) -> float:
|
|
843
|
+
"""``-2 loglik + log(nobs) df``."""
|
|
844
|
+
ll = self.loglik()
|
|
845
|
+
return -2 * ll.value + math.log(ll.nobs) * ll.df
|
|
846
|
+
|
|
847
|
+
def print(self, *, digits: int = 4) -> None:
|
|
848
|
+
"""Print the tree with leaf coefficients to ``digits`` significant
|
|
849
|
+
digits (``str(tree)`` uses 4, as R's ``getOption("digits") - 3``)."""
|
|
850
|
+
print(format_tree(self, digits))
|
|
851
|
+
|
|
852
|
+
def __str__(self) -> str:
|
|
853
|
+
return format_tree(self)
|
|
854
|
+
|
|
855
|
+
def summary(self, *, backend: Backend = None) -> TreeSummary:
|
|
856
|
+
"""The tree's tables and their printed form (section 3.4). The
|
|
857
|
+
tables follow ``coef_table``'s backend rules."""
|
|
858
|
+
name = resolve_backend(backend, self._backend)
|
|
859
|
+
variables, nodes, tests = summary_tables(self)
|
|
860
|
+
design = self._design
|
|
861
|
+
return TreeSummary(
|
|
862
|
+
variables=make_table(variables, name),
|
|
863
|
+
nodes=make_table(nodes, name),
|
|
864
|
+
tests=make_table(tests, name),
|
|
865
|
+
coefficients=make_table(coef_columns(self, "wide"), name),
|
|
866
|
+
dropped_levels={} if design is None else dict(design.dropped_levels),
|
|
867
|
+
dropped_rows=self.n_rows - self.n_used,
|
|
868
|
+
missing_counts={} if design is None else dict(design.missing_counts),
|
|
869
|
+
warnings=self._warnings,
|
|
870
|
+
text=format_summary(self),
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
def __repr__(self) -> str:
|
|
874
|
+
n_inner = len(self._node_infos) - len(self.leaves)
|
|
875
|
+
what = self._formula if self._formula is not None else "mob()"
|
|
876
|
+
return (
|
|
877
|
+
f"<ModelTree {self._model_name}: {what}, {n_inner} inner and "
|
|
878
|
+
f"{len(self.leaves)} terminal nodes>"
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
# Entry points
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
class ControlOptions(TypedDict, total=False):
|
|
886
|
+
"""The :class:`~modeltrees.tree.MobControl` fields, as keywords of the
|
|
887
|
+
entry points (``lmtree(f, df, alpha=0.01)``). The last five raise
|
|
888
|
+
``NotImplementedError`` for anything but their defaults."""
|
|
889
|
+
|
|
890
|
+
alpha: float
|
|
891
|
+
bonferroni: bool
|
|
892
|
+
minsize: float | None
|
|
893
|
+
maxdepth: float
|
|
894
|
+
trim: float
|
|
895
|
+
dfsplit: bool
|
|
896
|
+
prune: str | PruneRule | None
|
|
897
|
+
restart: bool
|
|
898
|
+
caseweights: bool
|
|
899
|
+
numsplit: Literal["left", "center"]
|
|
900
|
+
catsplit: Literal["binary", "multiway"]
|
|
901
|
+
mtry: float
|
|
902
|
+
breakties: bool
|
|
903
|
+
ordinal: str
|
|
904
|
+
vcov: str
|
|
905
|
+
parm: Sequence[int] | Sequence[str] | None
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _control(control: MobControl | None, options: Mapping[str, Any]) -> MobControl:
|
|
909
|
+
unknown = sorted(set(options) - set(ControlOptions.__annotations__))
|
|
910
|
+
if unknown:
|
|
911
|
+
raise TypeError(
|
|
912
|
+
f"unexpected keyword argument(s): {', '.join(unknown)} (control options "
|
|
913
|
+
f"are {', '.join(ControlOptions.__annotations__)})"
|
|
914
|
+
)
|
|
915
|
+
if control is not None:
|
|
916
|
+
if options:
|
|
917
|
+
raise TypeError(
|
|
918
|
+
"pass either control=MobControl(...) or control options such as "
|
|
919
|
+
f"{next(iter(options))}=..., not both"
|
|
920
|
+
)
|
|
921
|
+
if not isinstance(control, MobControl):
|
|
922
|
+
raise TypeError(
|
|
923
|
+
f"control must be a MobControl, got {type(control).__name__}"
|
|
924
|
+
)
|
|
925
|
+
return control
|
|
926
|
+
return MobControl(**options)
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _check_n_jobs(n_jobs: int | None) -> None:
|
|
930
|
+
"""Fail before any work on an invalid ``n_jobs`` (section 6)."""
|
|
931
|
+
resolve_n_jobs(n_jobs)
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _merge(caught: Sequence[_warnings.WarningMessage]) -> tuple[RecordedWarning, ...]:
|
|
935
|
+
"""Identical (category, message) pairs merged, in order of first
|
|
936
|
+
occurrence (section 7.3)."""
|
|
937
|
+
counts: dict[tuple[type[Warning], str], int] = {}
|
|
938
|
+
for w in caught:
|
|
939
|
+
key = (w.category, str(w.message))
|
|
940
|
+
counts[key] = counts.get(key, 0) + 1
|
|
941
|
+
return tuple(RecordedWarning(c, m, n) for (c, m), n in counts.items())
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _recorded[T](
|
|
945
|
+
run: Callable[[], T], stacklevel: int
|
|
946
|
+
) -> tuple[T, tuple[RecordedWarning, ...]]:
|
|
947
|
+
"""Run ``run`` while recording its warnings, then re-emit each distinct
|
|
948
|
+
warning once, ``stacklevel`` frames up from here (the user's call).
|
|
949
|
+
|
|
950
|
+
Inside, every warning is recorded ("always"), so user filters such as
|
|
951
|
+
"error" apply only to the re-emitted warnings. Warnings recorded before
|
|
952
|
+
an exception are re-emitted before it propagates.
|
|
953
|
+
"""
|
|
954
|
+
caught: list[_warnings.WarningMessage] = []
|
|
955
|
+
try:
|
|
956
|
+
with _warnings.catch_warnings(record=True) as recording:
|
|
957
|
+
_warnings.simplefilter("always")
|
|
958
|
+
caught = recording
|
|
959
|
+
result = run()
|
|
960
|
+
finally:
|
|
961
|
+
recorded = _merge(caught)
|
|
962
|
+
for r in recorded:
|
|
963
|
+
text = r.message if r.count == 1 else f"{r.message} ({r.count} times)"
|
|
964
|
+
_warnings.warn(text, r.category, stacklevel=stacklevel + 1)
|
|
965
|
+
return result, recorded
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _formula_tree(
|
|
969
|
+
formula: str,
|
|
970
|
+
data: DataInput,
|
|
971
|
+
*,
|
|
972
|
+
rule: ResponseRule,
|
|
973
|
+
make_model: Callable[[tuple[str, ...], CodedResponse], NodeModel],
|
|
974
|
+
model_name: str,
|
|
975
|
+
weights: ColumnSpec,
|
|
976
|
+
offset: ColumnSpec,
|
|
977
|
+
cluster: ColumnSpec,
|
|
978
|
+
levels: Mapping[str, Sequence[Any]] | None,
|
|
979
|
+
kinds: Mapping[str, Kind] | None,
|
|
980
|
+
na_action: NaAction,
|
|
981
|
+
n_jobs: int | None,
|
|
982
|
+
control: MobControl | None,
|
|
983
|
+
options: Mapping[str, Any],
|
|
984
|
+
) -> ModelTree:
|
|
985
|
+
"""The shared body of the formula entry points."""
|
|
986
|
+
ctrl = _control(control, options)
|
|
987
|
+
_check_n_jobs(n_jobs)
|
|
988
|
+
|
|
989
|
+
def run() -> tuple[Design, CodedResponse, NodeModel, GrownTree]:
|
|
990
|
+
design = build_design(
|
|
991
|
+
formula,
|
|
992
|
+
data,
|
|
993
|
+
weights=weights,
|
|
994
|
+
offset=offset,
|
|
995
|
+
cluster=cluster,
|
|
996
|
+
levels=levels,
|
|
997
|
+
kinds=kinds,
|
|
998
|
+
na_action=na_action,
|
|
999
|
+
)
|
|
1000
|
+
coded = code_response(design.response, rule, weighted=weights is not None)
|
|
1001
|
+
model = make_model(design.coef_names, coded)
|
|
1002
|
+
grown = _grow(
|
|
1003
|
+
model,
|
|
1004
|
+
coded.values,
|
|
1005
|
+
design.x,
|
|
1006
|
+
design.partitioning,
|
|
1007
|
+
weights=design.weights,
|
|
1008
|
+
offset=design.offset,
|
|
1009
|
+
cluster=design.cluster,
|
|
1010
|
+
control=ctrl,
|
|
1011
|
+
n_jobs=n_jobs,
|
|
1012
|
+
)
|
|
1013
|
+
return design, coded, model, grown
|
|
1014
|
+
|
|
1015
|
+
# Frames: _formula_tree, the entry point, the user's call.
|
|
1016
|
+
(design, coded, model, grown), recorded = _recorded(run, 3)
|
|
1017
|
+
return ModelTree(
|
|
1018
|
+
grown,
|
|
1019
|
+
model,
|
|
1020
|
+
model_name=model_name,
|
|
1021
|
+
formula=design.formula.expanded,
|
|
1022
|
+
design=design,
|
|
1023
|
+
response_levels=coded.levels,
|
|
1024
|
+
warnings=recorded,
|
|
1025
|
+
used_rows=design.used_rows,
|
|
1026
|
+
x=design.x,
|
|
1027
|
+
offset=design.offset,
|
|
1028
|
+
boolean=_boolean_variables(design),
|
|
1029
|
+
backend=design.backend,
|
|
1030
|
+
y=coded.values,
|
|
1031
|
+
weights=design.weights,
|
|
1032
|
+
cluster=design.cluster,
|
|
1033
|
+
frame=Frame(data),
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def lmtree(
|
|
1038
|
+
formula: str,
|
|
1039
|
+
data: DataInput,
|
|
1040
|
+
*,
|
|
1041
|
+
weights: ColumnSpec = None,
|
|
1042
|
+
offset: ColumnSpec = None,
|
|
1043
|
+
cluster: ColumnSpec = None,
|
|
1044
|
+
levels: Mapping[str, Sequence[Any]] | None = None,
|
|
1045
|
+
kinds: Mapping[str, Kind] | None = None,
|
|
1046
|
+
na_action: NaAction = "omit",
|
|
1047
|
+
n_jobs: int | None = None,
|
|
1048
|
+
control: MobControl | None = None,
|
|
1049
|
+
**control_options: Unpack[ControlOptions],
|
|
1050
|
+
) -> ModelTree:
|
|
1051
|
+
"""A linear model tree, as ``partykit::lmtree``.
|
|
1052
|
+
|
|
1053
|
+
``formula`` is ``"y ~ x1 + x2 | z1 + z2"``: the regressors of the
|
|
1054
|
+
leaf models, then the partitioning variables. A one-part formula
|
|
1055
|
+
``"y ~ z1 + z2"`` means an intercept-only model partitioned by ``z1``
|
|
1056
|
+
and ``z2``, as in R (the tree's ``formula`` shows it expanded,
|
|
1057
|
+
``"y ~ 1 | z1 + z2"``). ``data`` is an eager dataframe (polars,
|
|
1058
|
+
pandas, ...) or a mapping of column names to arrays.
|
|
1059
|
+
|
|
1060
|
+
``weights``, ``offset`` and ``cluster`` are column names or arrays;
|
|
1061
|
+
``levels`` and ``kinds`` set level orders and variable kinds per column;
|
|
1062
|
+
``na_action="omit"`` drops incomplete rows with one
|
|
1063
|
+
:class:`~modeltrees.errors.MissingDataWarning`. Control options
|
|
1064
|
+
(``alpha``, ``minsize``, ``maxdepth``, ``prune``, ...) are keywords, or
|
|
1065
|
+
a :class:`~modeltrees.tree.MobControl` as ``control``, not both.
|
|
1066
|
+
``n_jobs`` > 1 fits the candidates of large split searches in that many
|
|
1067
|
+
worker processes (-1: every CPU), with results identical to the serial
|
|
1068
|
+
search; ``restart=False`` runs serially with a warning.
|
|
1069
|
+
"""
|
|
1070
|
+
return _formula_tree(
|
|
1071
|
+
formula,
|
|
1072
|
+
data,
|
|
1073
|
+
rule="gaussian",
|
|
1074
|
+
make_model=lambda names, _: LmModel(coef_names=names),
|
|
1075
|
+
model_name="lmtree",
|
|
1076
|
+
weights=weights,
|
|
1077
|
+
offset=offset,
|
|
1078
|
+
cluster=cluster,
|
|
1079
|
+
levels=levels,
|
|
1080
|
+
kinds=kinds,
|
|
1081
|
+
na_action=na_action,
|
|
1082
|
+
n_jobs=n_jobs,
|
|
1083
|
+
control=control,
|
|
1084
|
+
options=control_options,
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
_FAMILIES: tuple[str, ...] = ("gaussian", "binomial", "poisson")
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def glmtree(
|
|
1092
|
+
formula: str,
|
|
1093
|
+
data: DataInput,
|
|
1094
|
+
*,
|
|
1095
|
+
family: Literal["gaussian", "binomial", "poisson"] = "gaussian",
|
|
1096
|
+
epsilon: float = 1e-8,
|
|
1097
|
+
maxit: int = 25,
|
|
1098
|
+
weights: ColumnSpec = None,
|
|
1099
|
+
offset: ColumnSpec = None,
|
|
1100
|
+
cluster: ColumnSpec = None,
|
|
1101
|
+
levels: Mapping[str, Sequence[Any]] | None = None,
|
|
1102
|
+
kinds: Mapping[str, Kind] | None = None,
|
|
1103
|
+
na_action: NaAction = "omit",
|
|
1104
|
+
n_jobs: int | None = None,
|
|
1105
|
+
control: MobControl | None = None,
|
|
1106
|
+
**control_options: Unpack[ControlOptions],
|
|
1107
|
+
) -> ModelTree:
|
|
1108
|
+
"""A generalized linear model tree, as ``partykit::glmtree``.
|
|
1109
|
+
|
|
1110
|
+
``family`` is "gaussian", "binomial" or "poisson", each with its
|
|
1111
|
+
canonical link; ``epsilon`` and ``maxit`` are glm.fit's controls. A
|
|
1112
|
+
binomial response may be 0/1, Boolean, categorical (the first level is
|
|
1113
|
+
failure, every other level success, as in R) or proportions with the
|
|
1114
|
+
number of trials as ``weights``; a poisson response non-negative
|
|
1115
|
+
counts. The other arguments are those of :func:`lmtree`.
|
|
1116
|
+
"""
|
|
1117
|
+
if not isinstance(family, str) or family not in _FAMILIES:
|
|
1118
|
+
raise NotImplementedError(
|
|
1119
|
+
f"family {family!r} is not available; glmtree supports "
|
|
1120
|
+
f"{', '.join(_FAMILIES)} with their canonical links"
|
|
1121
|
+
)
|
|
1122
|
+
rule: ResponseRule = family
|
|
1123
|
+
return _formula_tree(
|
|
1124
|
+
formula,
|
|
1125
|
+
data,
|
|
1126
|
+
rule=rule,
|
|
1127
|
+
make_model=lambda names, _: GlmModel(
|
|
1128
|
+
family, epsilon=epsilon, maxit=maxit, coef_names=names
|
|
1129
|
+
),
|
|
1130
|
+
model_name=f"glmtree({family})",
|
|
1131
|
+
weights=weights,
|
|
1132
|
+
offset=offset,
|
|
1133
|
+
cluster=cluster,
|
|
1134
|
+
levels=levels,
|
|
1135
|
+
kinds=kinds,
|
|
1136
|
+
na_action=na_action,
|
|
1137
|
+
n_jobs=n_jobs,
|
|
1138
|
+
control=control,
|
|
1139
|
+
options=control_options,
|
|
1140
|
+
)
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def negbintree(
|
|
1144
|
+
formula: str,
|
|
1145
|
+
data: DataInput,
|
|
1146
|
+
*,
|
|
1147
|
+
objective: Literal["loglik", "deviance"] = "loglik",
|
|
1148
|
+
epsilon: float = 1e-8,
|
|
1149
|
+
maxit: int = 25,
|
|
1150
|
+
weights: ColumnSpec = None,
|
|
1151
|
+
offset: ColumnSpec = None,
|
|
1152
|
+
cluster: ColumnSpec = None,
|
|
1153
|
+
levels: Mapping[str, Sequence[Any]] | None = None,
|
|
1154
|
+
kinds: Mapping[str, Kind] | None = None,
|
|
1155
|
+
na_action: NaAction = "omit",
|
|
1156
|
+
n_jobs: int | None = None,
|
|
1157
|
+
control: MobControl | None = None,
|
|
1158
|
+
**control_options: Unpack[ControlOptions],
|
|
1159
|
+
) -> ModelTree:
|
|
1160
|
+
"""A negative binomial regression tree with theta estimated per leaf.
|
|
1161
|
+
|
|
1162
|
+
The model follows Rusch's thesis: only the regression coefficients are
|
|
1163
|
+
tested for instability, so a change in overdispersion alone does not
|
|
1164
|
+
split. ``objective="deviance"`` compares candidate splits by deviance,
|
|
1165
|
+
as party 1.0-1 did in the thesis. The response must be non-negative
|
|
1166
|
+
counts. The other arguments are those of :func:`lmtree`.
|
|
1167
|
+
"""
|
|
1168
|
+
return _formula_tree(
|
|
1169
|
+
formula,
|
|
1170
|
+
data,
|
|
1171
|
+
rule="negbin",
|
|
1172
|
+
make_model=lambda names, _: NegBinModel(
|
|
1173
|
+
objective=objective, epsilon=epsilon, maxit=maxit, coef_names=names
|
|
1174
|
+
),
|
|
1175
|
+
model_name="negbintree",
|
|
1176
|
+
weights=weights,
|
|
1177
|
+
offset=offset,
|
|
1178
|
+
cluster=cluster,
|
|
1179
|
+
levels=levels,
|
|
1180
|
+
kinds=kinds,
|
|
1181
|
+
na_action=na_action,
|
|
1182
|
+
n_jobs=n_jobs,
|
|
1183
|
+
control=control,
|
|
1184
|
+
options=control_options,
|
|
1185
|
+
)
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def safelogittree(
|
|
1189
|
+
formula: str,
|
|
1190
|
+
data: DataInput,
|
|
1191
|
+
*,
|
|
1192
|
+
separation_tol: float = 1e-6,
|
|
1193
|
+
epsilon: float = 1e-8,
|
|
1194
|
+
maxit: int = 25,
|
|
1195
|
+
weights: ColumnSpec = None,
|
|
1196
|
+
offset: ColumnSpec = None,
|
|
1197
|
+
cluster: ColumnSpec = None,
|
|
1198
|
+
levels: Mapping[str, Sequence[Any]] | None = None,
|
|
1199
|
+
kinds: Mapping[str, Kind] | None = None,
|
|
1200
|
+
na_action: NaAction = "omit",
|
|
1201
|
+
n_jobs: int | None = None,
|
|
1202
|
+
control: MobControl | None = None,
|
|
1203
|
+
**control_options: Unpack[ControlOptions],
|
|
1204
|
+
) -> ModelTree:
|
|
1205
|
+
"""A binary logistic regression tree that tolerates separation.
|
|
1206
|
+
|
|
1207
|
+
This project's design, not Rusch's implementation (the thesis names a
|
|
1208
|
+
safe logit model but gives no code; docs/extensions-spec.md). A
|
|
1209
|
+
completely separated leaf (every row fitted within ``separation_tol``)
|
|
1210
|
+
keeps its fit but has no scores and is terminal. The response must be
|
|
1211
|
+
binary: 0/1, Boolean or categorical (first level 0, the rest 1);
|
|
1212
|
+
proportions raise. The other arguments are those of :func:`lmtree`.
|
|
1213
|
+
"""
|
|
1214
|
+
return _formula_tree(
|
|
1215
|
+
formula,
|
|
1216
|
+
data,
|
|
1217
|
+
rule="safelogit",
|
|
1218
|
+
make_model=lambda names, _: SafeLogitModel(
|
|
1219
|
+
separation_tol=separation_tol,
|
|
1220
|
+
epsilon=epsilon,
|
|
1221
|
+
maxit=maxit,
|
|
1222
|
+
coef_names=names,
|
|
1223
|
+
),
|
|
1224
|
+
model_name="safelogittree",
|
|
1225
|
+
weights=weights,
|
|
1226
|
+
offset=offset,
|
|
1227
|
+
cluster=cluster,
|
|
1228
|
+
levels=levels,
|
|
1229
|
+
kinds=kinds,
|
|
1230
|
+
na_action=na_action,
|
|
1231
|
+
n_jobs=n_jobs,
|
|
1232
|
+
control=control,
|
|
1233
|
+
options=control_options,
|
|
1234
|
+
)
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
def multinomtree(
|
|
1238
|
+
formula: str,
|
|
1239
|
+
data: DataInput,
|
|
1240
|
+
*,
|
|
1241
|
+
gtol: float = 1e-12,
|
|
1242
|
+
maxit: int = 100,
|
|
1243
|
+
weights: ColumnSpec = None,
|
|
1244
|
+
offset: ColumnSpec = None,
|
|
1245
|
+
cluster: ColumnSpec = None,
|
|
1246
|
+
levels: Mapping[str, Sequence[Any]] | None = None,
|
|
1247
|
+
kinds: Mapping[str, Kind] | None = None,
|
|
1248
|
+
na_action: NaAction = "omit",
|
|
1249
|
+
n_jobs: int | None = None,
|
|
1250
|
+
control: MobControl | None = None,
|
|
1251
|
+
**control_options: Unpack[ControlOptions],
|
|
1252
|
+
) -> ModelTree:
|
|
1253
|
+
"""A multinomial logit tree, fitted by Newton's method in each node.
|
|
1254
|
+
|
|
1255
|
+
This project's design, not Rusch's implementation
|
|
1256
|
+
(docs/extensions-spec.md). The response must be categorical, or integer
|
|
1257
|
+
with ``levels``; its first level (after unused levels are dropped) is
|
|
1258
|
+
the reference class, and the tree's ``response_levels`` records the
|
|
1259
|
+
order. An offset is not supported. The other arguments are those of
|
|
1260
|
+
:func:`lmtree`.
|
|
1261
|
+
"""
|
|
1262
|
+
if offset is not None:
|
|
1263
|
+
raise TypeError("multinomtree does not support an offset")
|
|
1264
|
+
return _formula_tree(
|
|
1265
|
+
formula,
|
|
1266
|
+
data,
|
|
1267
|
+
rule="multinom",
|
|
1268
|
+
make_model=lambda names, coded: MultinomModel(
|
|
1269
|
+
coded.levels or (), coef_names=names, gtol=gtol, maxit=maxit
|
|
1270
|
+
),
|
|
1271
|
+
model_name="multinomtree",
|
|
1272
|
+
weights=weights,
|
|
1273
|
+
offset=offset,
|
|
1274
|
+
cluster=cluster,
|
|
1275
|
+
levels=levels,
|
|
1276
|
+
kinds=kinds,
|
|
1277
|
+
na_action=na_action,
|
|
1278
|
+
n_jobs=n_jobs,
|
|
1279
|
+
control=control,
|
|
1280
|
+
options=control_options,
|
|
1281
|
+
)
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def mob(
|
|
1285
|
+
model: NodeModel,
|
|
1286
|
+
y: ArrayLike,
|
|
1287
|
+
x: ArrayLike | None,
|
|
1288
|
+
z: Mapping[str, Any],
|
|
1289
|
+
*,
|
|
1290
|
+
weights: ArrayLike | None = None,
|
|
1291
|
+
offset: ArrayLike | None = None,
|
|
1292
|
+
cluster: ArrayLike | None = None,
|
|
1293
|
+
control: MobControl | None = None,
|
|
1294
|
+
n_jobs: int | None = None,
|
|
1295
|
+
) -> ModelTree:
|
|
1296
|
+
"""Grow a tree with any node model on arrays, as ``partykit::mob``.
|
|
1297
|
+
|
|
1298
|
+
The array path for custom models: ``y`` the response as the model takes
|
|
1299
|
+
it, ``x`` the regressor matrix with its intercept column (or None for
|
|
1300
|
+
an intercept-only model), ``z`` the partitioning variables, each a
|
|
1301
|
+
:class:`~modeltrees.tree.Variable`, a dict with ``kind``, ``values``
|
|
1302
|
+
and optionally ``levels``, or a tuple ``(kind, values[, levels])``.
|
|
1303
|
+
There is no missing-data handling: every row is used.
|
|
1304
|
+
"""
|
|
1305
|
+
_check_n_jobs(n_jobs)
|
|
1306
|
+
if control is not None and not isinstance(control, MobControl):
|
|
1307
|
+
raise TypeError(f"control must be a MobControl, got {type(control).__name__}")
|
|
1308
|
+
xv = None if x is None else np.asarray(x, dtype=np.float64)
|
|
1309
|
+
off = None if offset is None else np.asarray(offset, dtype=np.float64)
|
|
1310
|
+
|
|
1311
|
+
def run() -> GrownTree:
|
|
1312
|
+
return _grow(
|
|
1313
|
+
model,
|
|
1314
|
+
y,
|
|
1315
|
+
xv,
|
|
1316
|
+
z,
|
|
1317
|
+
weights=weights,
|
|
1318
|
+
offset=off,
|
|
1319
|
+
cluster=cluster,
|
|
1320
|
+
control=control,
|
|
1321
|
+
n_jobs=n_jobs,
|
|
1322
|
+
)
|
|
1323
|
+
|
|
1324
|
+
grown, recorded = _recorded(run, 2) # mob, the user's call
|
|
1325
|
+
levels = getattr(model, "levels", None)
|
|
1326
|
+
return ModelTree(
|
|
1327
|
+
grown,
|
|
1328
|
+
model,
|
|
1329
|
+
model_name=f"mob({type(model).__name__})",
|
|
1330
|
+
formula=None,
|
|
1331
|
+
design=None,
|
|
1332
|
+
response_levels=None if levels is None else tuple(str(v) for v in levels),
|
|
1333
|
+
warnings=recorded,
|
|
1334
|
+
used_rows=np.ones(grown.fitted_node.shape[0], dtype=np.bool_),
|
|
1335
|
+
x=xv,
|
|
1336
|
+
offset=off,
|
|
1337
|
+
y=np.asarray(y),
|
|
1338
|
+
weights=np.ones(grown.fitted_node.shape[0])
|
|
1339
|
+
if weights is None
|
|
1340
|
+
else np.asarray(weights, dtype=np.float64),
|
|
1341
|
+
cluster=None if cluster is None else np.asarray(cluster),
|
|
1342
|
+
)
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def _boolean_variables(design: Design) -> frozenset[str]:
|
|
1346
|
+
"""Partitioning variables that come from Boolean columns."""
|
|
1347
|
+
return frozenset(
|
|
1348
|
+
t.name
|
|
1349
|
+
for t in design.partitioning_terms
|
|
1350
|
+
if t.storage == "boolean" and t.kind == "numeric"
|
|
1351
|
+
)
|