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/_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
+ )