seqtrees 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.
- seqtrees/__init__.py +6 -0
- seqtrees/_backends.py +81 -0
- seqtrees/_distributions.py +57 -0
- seqtrees/_lightgbm_tree.py +109 -0
- seqtrees/_parallel.py +27 -0
- seqtrees/_sklearn_tree.py +89 -0
- seqtrees/_tree.py +149 -0
- seqtrees/_utils.py +150 -0
- seqtrees/synthesizer.py +407 -0
- seqtrees-0.1.0.dist-info/METADATA +199 -0
- seqtrees-0.1.0.dist-info/RECORD +14 -0
- seqtrees-0.1.0.dist-info/WHEEL +5 -0
- seqtrees-0.1.0.dist-info/licenses/LICENSE +190 -0
- seqtrees-0.1.0.dist-info/top_level.txt +1 -0
seqtrees/__init__.py
ADDED
seqtrees/_backends.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ._tree import ConditionalTree
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
SUPPORTED_TREE_BACKENDS = {"native", "sklearn", "lightgbm", "auto"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_tree_backend(
|
|
12
|
+
backend: str,
|
|
13
|
+
*,
|
|
14
|
+
max_depth: int,
|
|
15
|
+
min_samples_leaf: int,
|
|
16
|
+
min_impurity_decrease: float,
|
|
17
|
+
max_thresholds: int,
|
|
18
|
+
random_state: int | None = None,
|
|
19
|
+
n_jobs: int | None = None,
|
|
20
|
+
) -> Any:
|
|
21
|
+
if backend not in SUPPORTED_TREE_BACKENDS:
|
|
22
|
+
raise ValueError(f"tree_backend must be one of {sorted(SUPPORTED_TREE_BACKENDS)}.")
|
|
23
|
+
|
|
24
|
+
if backend == "sklearn":
|
|
25
|
+
from ._sklearn_tree import SklearnConditionalTree
|
|
26
|
+
|
|
27
|
+
return SklearnConditionalTree(
|
|
28
|
+
max_depth=max_depth,
|
|
29
|
+
min_samples_leaf=min_samples_leaf,
|
|
30
|
+
min_impurity_decrease=min_impurity_decrease,
|
|
31
|
+
max_thresholds=max_thresholds,
|
|
32
|
+
random_state=random_state,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if backend == "lightgbm":
|
|
36
|
+
from ._lightgbm_tree import LightGBMConditionalTree
|
|
37
|
+
|
|
38
|
+
return LightGBMConditionalTree(
|
|
39
|
+
max_depth=max_depth,
|
|
40
|
+
min_samples_leaf=min_samples_leaf,
|
|
41
|
+
min_impurity_decrease=min_impurity_decrease,
|
|
42
|
+
max_thresholds=max_thresholds,
|
|
43
|
+
random_state=random_state,
|
|
44
|
+
n_jobs=n_jobs,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if backend == "auto":
|
|
48
|
+
try:
|
|
49
|
+
from ._lightgbm_tree import LightGBMConditionalTree
|
|
50
|
+
import lightgbm # noqa: F401
|
|
51
|
+
|
|
52
|
+
return LightGBMConditionalTree(
|
|
53
|
+
max_depth=max_depth,
|
|
54
|
+
min_samples_leaf=min_samples_leaf,
|
|
55
|
+
min_impurity_decrease=min_impurity_decrease,
|
|
56
|
+
max_thresholds=max_thresholds,
|
|
57
|
+
random_state=random_state,
|
|
58
|
+
n_jobs=n_jobs,
|
|
59
|
+
)
|
|
60
|
+
except ImportError:
|
|
61
|
+
pass
|
|
62
|
+
try:
|
|
63
|
+
from ._sklearn_tree import SklearnConditionalTree
|
|
64
|
+
import sklearn # noqa: F401
|
|
65
|
+
|
|
66
|
+
return SklearnConditionalTree(
|
|
67
|
+
max_depth=max_depth,
|
|
68
|
+
min_samples_leaf=min_samples_leaf,
|
|
69
|
+
min_impurity_decrease=min_impurity_decrease,
|
|
70
|
+
max_thresholds=max_thresholds,
|
|
71
|
+
random_state=random_state,
|
|
72
|
+
)
|
|
73
|
+
except ImportError:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
return ConditionalTree(
|
|
77
|
+
max_depth=max_depth,
|
|
78
|
+
min_samples_leaf=min_samples_leaf,
|
|
79
|
+
min_impurity_decrease=min_impurity_decrease,
|
|
80
|
+
max_thresholds=max_thresholds,
|
|
81
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import random
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ._utils import is_number
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EmpiricalDistribution:
|
|
12
|
+
"""A finite empirical distribution over observed values."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, values: list[Any]):
|
|
15
|
+
if not values:
|
|
16
|
+
raise ValueError("EmpiricalDistribution requires at least one value.")
|
|
17
|
+
counts = Counter(values)
|
|
18
|
+
self.values_ = list(counts.keys())
|
|
19
|
+
self.weights_ = [counts[value] for value in self.values_]
|
|
20
|
+
self.n_samples_ = len(values)
|
|
21
|
+
|
|
22
|
+
def entropy(self) -> float:
|
|
23
|
+
total = sum(self.weights_)
|
|
24
|
+
score = 0.0
|
|
25
|
+
for weight in self.weights_:
|
|
26
|
+
probability = weight / total
|
|
27
|
+
score -= probability * math.log2(probability)
|
|
28
|
+
return score
|
|
29
|
+
|
|
30
|
+
def sample(self, rng: random.Random) -> Any:
|
|
31
|
+
return rng.choices(self.values_, weights=self.weights_, k=1)[0]
|
|
32
|
+
|
|
33
|
+
def sample_interpolated(self, rng: random.Random) -> Any:
|
|
34
|
+
numeric_values = [value for value in self.values_ if is_number(value)]
|
|
35
|
+
if len(numeric_values) < 2:
|
|
36
|
+
return self.sample(rng)
|
|
37
|
+
|
|
38
|
+
left = self.sample(rng)
|
|
39
|
+
right = self.sample(rng)
|
|
40
|
+
if not is_number(left) or not is_number(right):
|
|
41
|
+
return self.sample(rng)
|
|
42
|
+
if left == right:
|
|
43
|
+
return left
|
|
44
|
+
weight = rng.random()
|
|
45
|
+
return left + weight * (right - left)
|
|
46
|
+
|
|
47
|
+
def probability(self, value: Any, alpha: float = 1.0) -> float:
|
|
48
|
+
total = sum(self.weights_)
|
|
49
|
+
size = len(self.values_)
|
|
50
|
+
for candidate, weight in zip(self.values_, self.weights_):
|
|
51
|
+
if candidate == value:
|
|
52
|
+
return (weight + alpha) / (total + alpha * size)
|
|
53
|
+
return alpha / (total + alpha * (size + 1))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def entropy(values: list[Any]) -> float:
|
|
57
|
+
return EmpiricalDistribution(values).entropy()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ._distributions import EmpiricalDistribution
|
|
6
|
+
from ._utils import require_numeric_values
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LightGBMConditionalTree:
|
|
10
|
+
"""Conditional sampler backed by LightGBM's histogram regression trees."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
*,
|
|
15
|
+
max_depth: int = 5,
|
|
16
|
+
min_samples_leaf: int = 5,
|
|
17
|
+
min_impurity_decrease: float = 1e-9,
|
|
18
|
+
max_thresholds: int = 16,
|
|
19
|
+
random_state: int | None = None,
|
|
20
|
+
n_jobs: int | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
del max_thresholds
|
|
23
|
+
self.max_depth = max_depth
|
|
24
|
+
self.min_samples_leaf = min_samples_leaf
|
|
25
|
+
self.min_impurity_decrease = min_impurity_decrease
|
|
26
|
+
self.random_state = random_state
|
|
27
|
+
self.n_jobs = n_jobs
|
|
28
|
+
|
|
29
|
+
def fit(self, records: list[dict[str, Any]], features: list[str], target: str) -> "LightGBMConditionalTree":
|
|
30
|
+
try:
|
|
31
|
+
from lightgbm import LGBMRegressor
|
|
32
|
+
except ImportError as exc:
|
|
33
|
+
raise ImportError("Install seqtrees[lightgbm] to use tree_backend='lightgbm'.") from exc
|
|
34
|
+
|
|
35
|
+
self.features_ = list(features)
|
|
36
|
+
self.target_ = target
|
|
37
|
+
target_values = [record[target] for record in records]
|
|
38
|
+
self.marginal_ = EmpiricalDistribution(target_values)
|
|
39
|
+
require_numeric_values(records, self.features_ + [target], backend="lightgbm")
|
|
40
|
+
|
|
41
|
+
if self.max_depth == 0 or not self.features_ or len(set(target_values)) <= 1:
|
|
42
|
+
self.model_ = None
|
|
43
|
+
self.leaf_distributions_ = {}
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
x_matrix = [self._encode_features(record) for record in records]
|
|
47
|
+
|
|
48
|
+
max_depth = -1 if self.max_depth is None else self.max_depth
|
|
49
|
+
num_leaves = 31 if max_depth <= 0 else max(2, min(31, 2**max_depth))
|
|
50
|
+
self.model_ = LGBMRegressor(
|
|
51
|
+
boosting_type="gbdt",
|
|
52
|
+
objective="regression",
|
|
53
|
+
n_estimators=32,
|
|
54
|
+
learning_rate=0.2,
|
|
55
|
+
num_leaves=num_leaves,
|
|
56
|
+
max_depth=max_depth,
|
|
57
|
+
min_child_samples=self.min_samples_leaf,
|
|
58
|
+
min_split_gain=self.min_impurity_decrease,
|
|
59
|
+
random_state=self.random_state,
|
|
60
|
+
n_jobs=self.n_jobs,
|
|
61
|
+
verbosity=-1,
|
|
62
|
+
)
|
|
63
|
+
self.model_.fit(x_matrix, target_values)
|
|
64
|
+
|
|
65
|
+
grouped_values: dict[tuple[int, ...], list[Any]] = {}
|
|
66
|
+
for leaf_id, value in zip(self._leaf_ids(x_matrix), target_values):
|
|
67
|
+
grouped_values.setdefault(leaf_id, []).append(value)
|
|
68
|
+
self.leaf_distributions_ = {
|
|
69
|
+
leaf_id: EmpiricalDistribution(values) for leaf_id, values in grouped_values.items()
|
|
70
|
+
}
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def sample(self, row: dict[str, Any], rng, *, interpolate: bool = False) -> Any:
|
|
74
|
+
distribution = self._distribution_for_row(row)
|
|
75
|
+
if interpolate:
|
|
76
|
+
return distribution.sample_interpolated(rng)
|
|
77
|
+
return distribution.sample(rng)
|
|
78
|
+
|
|
79
|
+
def leaf_probability(self, row: dict[str, Any], value: Any, alpha: float = 1.0) -> float:
|
|
80
|
+
return self._distribution_for_row(row).probability(value, alpha=alpha)
|
|
81
|
+
|
|
82
|
+
def conditional_entropy(self, records: list[dict[str, Any]]) -> float:
|
|
83
|
+
if not records:
|
|
84
|
+
return 0.0
|
|
85
|
+
total = 0.0
|
|
86
|
+
for record in records:
|
|
87
|
+
total += self._distribution_for_row(record).entropy()
|
|
88
|
+
return total / len(records)
|
|
89
|
+
|
|
90
|
+
def _distribution_for_row(self, row: dict[str, Any]) -> EmpiricalDistribution:
|
|
91
|
+
if self.model_ is None:
|
|
92
|
+
return self.marginal_
|
|
93
|
+
leaf_id = self._leaf_ids([self._encode_features(row)])[0]
|
|
94
|
+
return self.leaf_distributions_.get(leaf_id, self.marginal_)
|
|
95
|
+
|
|
96
|
+
def _encode_features(self, record: dict[str, Any]) -> list[int | float]:
|
|
97
|
+
return [float("nan") if record.get(feature) is None else record.get(feature) for feature in self.features_]
|
|
98
|
+
|
|
99
|
+
def _leaf_ids(self, x_matrix: list[list[int | float]]) -> list[tuple[int, ...]]:
|
|
100
|
+
leaves = self.model_.predict(x_matrix, pred_leaf=True)
|
|
101
|
+
result = []
|
|
102
|
+
for row in leaves:
|
|
103
|
+
if hasattr(row, "tolist"):
|
|
104
|
+
row = row.tolist()
|
|
105
|
+
if isinstance(row, list):
|
|
106
|
+
result.append(tuple(int(value) for value in row))
|
|
107
|
+
else:
|
|
108
|
+
result.append((int(row),))
|
|
109
|
+
return result
|
seqtrees/_parallel.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
from typing import Callable, Iterable, TypeVar
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
R = TypeVar("R")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def resolve_n_jobs(n_jobs: int | None) -> int:
|
|
12
|
+
if n_jobs is None:
|
|
13
|
+
return 1
|
|
14
|
+
if n_jobs == 0:
|
|
15
|
+
raise ValueError("n_jobs cannot be 0. Use None, 1, a positive integer, or -1 for all cores.")
|
|
16
|
+
if n_jobs < 0:
|
|
17
|
+
return max(1, os.cpu_count() or 1)
|
|
18
|
+
return n_jobs
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parallel_map(func: Callable[[T], R], items: Iterable[T], n_jobs: int | None) -> list[R]:
|
|
22
|
+
values = list(items)
|
|
23
|
+
workers = resolve_n_jobs(n_jobs)
|
|
24
|
+
if workers == 1 or len(values) <= 1:
|
|
25
|
+
return [func(value) for value in values]
|
|
26
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
27
|
+
return list(executor.map(func, values))
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ._distributions import EmpiricalDistribution
|
|
6
|
+
from ._utils import require_numeric_values
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SklearnConditionalTree:
|
|
10
|
+
"""Conditional sampler backed by scikit-learn's compiled regression tree."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
*,
|
|
15
|
+
max_depth: int = 5,
|
|
16
|
+
min_samples_leaf: int = 5,
|
|
17
|
+
min_impurity_decrease: float = 1e-9,
|
|
18
|
+
max_thresholds: int = 16,
|
|
19
|
+
random_state: int | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
del max_thresholds
|
|
22
|
+
self.max_depth = max_depth
|
|
23
|
+
self.min_samples_leaf = min_samples_leaf
|
|
24
|
+
self.min_impurity_decrease = min_impurity_decrease
|
|
25
|
+
self.random_state = random_state
|
|
26
|
+
|
|
27
|
+
def fit(self, records: list[dict[str, Any]], features: list[str], target: str) -> "SklearnConditionalTree":
|
|
28
|
+
try:
|
|
29
|
+
from sklearn.tree import DecisionTreeRegressor
|
|
30
|
+
except ImportError as exc:
|
|
31
|
+
raise ImportError("Install seqtrees[sklearn] to use tree_backend='sklearn'.") from exc
|
|
32
|
+
|
|
33
|
+
self.features_ = list(features)
|
|
34
|
+
self.target_ = target
|
|
35
|
+
target_values = [record[target] for record in records]
|
|
36
|
+
self.marginal_ = EmpiricalDistribution(target_values)
|
|
37
|
+
require_numeric_values(records, self.features_ + [target], backend="sklearn")
|
|
38
|
+
|
|
39
|
+
if self.max_depth == 0 or not self.features_ or len(set(target_values)) <= 1:
|
|
40
|
+
self.model_ = None
|
|
41
|
+
self.leaf_distributions_ = {}
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
x_matrix = [self._encode_features(record) for record in records]
|
|
45
|
+
|
|
46
|
+
max_depth = None if self.max_depth is None else self.max_depth
|
|
47
|
+
self.model_ = DecisionTreeRegressor(
|
|
48
|
+
criterion="squared_error",
|
|
49
|
+
max_depth=max_depth,
|
|
50
|
+
min_samples_leaf=self.min_samples_leaf,
|
|
51
|
+
min_impurity_decrease=self.min_impurity_decrease,
|
|
52
|
+
random_state=self.random_state,
|
|
53
|
+
)
|
|
54
|
+
self.model_.fit(x_matrix, target_values)
|
|
55
|
+
|
|
56
|
+
leaves = self.model_.apply(x_matrix)
|
|
57
|
+
grouped_values: dict[int, list[Any]] = {}
|
|
58
|
+
for leaf_id, value in zip(leaves, target_values):
|
|
59
|
+
grouped_values.setdefault(int(leaf_id), []).append(value)
|
|
60
|
+
self.leaf_distributions_ = {
|
|
61
|
+
leaf_id: EmpiricalDistribution(values) for leaf_id, values in grouped_values.items()
|
|
62
|
+
}
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def sample(self, row: dict[str, Any], rng, *, interpolate: bool = False) -> Any:
|
|
66
|
+
distribution = self._distribution_for_row(row)
|
|
67
|
+
if interpolate:
|
|
68
|
+
return distribution.sample_interpolated(rng)
|
|
69
|
+
return distribution.sample(rng)
|
|
70
|
+
|
|
71
|
+
def leaf_probability(self, row: dict[str, Any], value: Any, alpha: float = 1.0) -> float:
|
|
72
|
+
return self._distribution_for_row(row).probability(value, alpha=alpha)
|
|
73
|
+
|
|
74
|
+
def conditional_entropy(self, records: list[dict[str, Any]]) -> float:
|
|
75
|
+
if not records:
|
|
76
|
+
return 0.0
|
|
77
|
+
total = 0.0
|
|
78
|
+
for record in records:
|
|
79
|
+
total += self._distribution_for_row(record).entropy()
|
|
80
|
+
return total / len(records)
|
|
81
|
+
|
|
82
|
+
def _distribution_for_row(self, row: dict[str, Any]) -> EmpiricalDistribution:
|
|
83
|
+
if self.model_ is None:
|
|
84
|
+
return self.marginal_
|
|
85
|
+
leaf_id = int(self.model_.apply([self._encode_features(row)])[0])
|
|
86
|
+
return self.leaf_distributions_.get(leaf_id, self.marginal_)
|
|
87
|
+
|
|
88
|
+
def _encode_features(self, record: dict[str, Any]) -> list[int]:
|
|
89
|
+
return [record.get(feature) for feature in self.features_]
|
seqtrees/_tree.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ._distributions import EmpiricalDistribution, entropy
|
|
7
|
+
from ._utils import is_number
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Split:
|
|
12
|
+
feature: str
|
|
13
|
+
threshold: float | None = None
|
|
14
|
+
category: Any | None = None
|
|
15
|
+
|
|
16
|
+
def go_left(self, row: dict[str, Any]) -> bool:
|
|
17
|
+
value = row.get(self.feature)
|
|
18
|
+
if self.threshold is not None:
|
|
19
|
+
return is_number(value) and value <= self.threshold
|
|
20
|
+
return value == self.category
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def label(self) -> str:
|
|
24
|
+
if self.threshold is not None:
|
|
25
|
+
return f"{self.feature} <= {self.threshold:g}"
|
|
26
|
+
return f"{self.feature} == {self.category!r}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class TreeNode:
|
|
31
|
+
distribution: EmpiricalDistribution
|
|
32
|
+
split: Split | None = None
|
|
33
|
+
left: "TreeNode | None" = None
|
|
34
|
+
right: "TreeNode | None" = None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_leaf(self) -> bool:
|
|
38
|
+
return self.split is None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConditionalTree:
|
|
42
|
+
"""Small CART-style conditional sampler used internally by SeqTrees."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
max_depth: int = 5,
|
|
48
|
+
min_samples_leaf: int = 5,
|
|
49
|
+
min_impurity_decrease: float = 1e-9,
|
|
50
|
+
max_thresholds: int = 16,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.max_depth = max_depth
|
|
53
|
+
self.min_samples_leaf = min_samples_leaf
|
|
54
|
+
self.min_impurity_decrease = min_impurity_decrease
|
|
55
|
+
self.max_thresholds = max_thresholds
|
|
56
|
+
|
|
57
|
+
def fit(self, records: list[dict[str, Any]], features: list[str], target: str) -> "ConditionalTree":
|
|
58
|
+
self.features_ = list(features)
|
|
59
|
+
self.target_ = target
|
|
60
|
+
self.root_ = self._fit_node(records, depth=0)
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def sample(self, row: dict[str, Any], rng, *, interpolate: bool = False) -> Any:
|
|
64
|
+
node = self.root_
|
|
65
|
+
while not node.is_leaf:
|
|
66
|
+
node = node.left if node.split.go_left(row) else node.right
|
|
67
|
+
if interpolate:
|
|
68
|
+
return node.distribution.sample_interpolated(rng)
|
|
69
|
+
return node.distribution.sample(rng)
|
|
70
|
+
|
|
71
|
+
def leaf_probability(self, row: dict[str, Any], value: Any, alpha: float = 1.0) -> float:
|
|
72
|
+
node = self.root_
|
|
73
|
+
while not node.is_leaf:
|
|
74
|
+
node = node.left if node.split.go_left(row) else node.right
|
|
75
|
+
return node.distribution.probability(value, alpha=alpha)
|
|
76
|
+
|
|
77
|
+
def conditional_entropy(self, records: list[dict[str, Any]]) -> float:
|
|
78
|
+
if not records:
|
|
79
|
+
return 0.0
|
|
80
|
+
total = 0.0
|
|
81
|
+
for record in records:
|
|
82
|
+
node = self.root_
|
|
83
|
+
while not node.is_leaf:
|
|
84
|
+
node = node.left if node.split.go_left(record) else node.right
|
|
85
|
+
total += node.distribution.entropy()
|
|
86
|
+
return total / len(records)
|
|
87
|
+
|
|
88
|
+
def _fit_node(self, records: list[dict[str, Any]], depth: int) -> TreeNode:
|
|
89
|
+
values = [record[self.target_] for record in records]
|
|
90
|
+
distribution = EmpiricalDistribution(values)
|
|
91
|
+
node = TreeNode(distribution=distribution)
|
|
92
|
+
|
|
93
|
+
if depth >= self.max_depth or len(records) < 2 * self.min_samples_leaf or distribution.entropy() == 0:
|
|
94
|
+
return node
|
|
95
|
+
|
|
96
|
+
split, gain = self._best_split(records)
|
|
97
|
+
if split is None or gain <= self.min_impurity_decrease:
|
|
98
|
+
return node
|
|
99
|
+
|
|
100
|
+
left_records, right_records = self._partition(records, split)
|
|
101
|
+
node.split = split
|
|
102
|
+
node.left = self._fit_node(left_records, depth + 1)
|
|
103
|
+
node.right = self._fit_node(right_records, depth + 1)
|
|
104
|
+
return node
|
|
105
|
+
|
|
106
|
+
def _best_split(self, records: list[dict[str, Any]]) -> tuple[Split | None, float]:
|
|
107
|
+
parent_entropy = entropy([record[self.target_] for record in records])
|
|
108
|
+
best_split = None
|
|
109
|
+
best_gain = 0.0
|
|
110
|
+
for feature in self.features_:
|
|
111
|
+
for split in self._candidate_splits(records, feature):
|
|
112
|
+
left_records, right_records = self._partition(records, split)
|
|
113
|
+
if len(left_records) < self.min_samples_leaf or len(right_records) < self.min_samples_leaf:
|
|
114
|
+
continue
|
|
115
|
+
weighted_entropy = (
|
|
116
|
+
len(left_records) * entropy([record[self.target_] for record in left_records])
|
|
117
|
+
+ len(right_records) * entropy([record[self.target_] for record in right_records])
|
|
118
|
+
) / len(records)
|
|
119
|
+
gain = parent_entropy - weighted_entropy
|
|
120
|
+
if gain > best_gain:
|
|
121
|
+
best_split = split
|
|
122
|
+
best_gain = gain
|
|
123
|
+
return best_split, best_gain
|
|
124
|
+
|
|
125
|
+
def _candidate_splits(self, records: list[dict[str, Any]], feature: str) -> list[Split]:
|
|
126
|
+
values = [record.get(feature) for record in records if record.get(feature) is not None]
|
|
127
|
+
unique = sorted(set(values)) if all(is_number(value) for value in values) else list(dict.fromkeys(values))
|
|
128
|
+
if len(unique) <= 1:
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
if all(is_number(value) for value in unique) and len(unique) > 2:
|
|
132
|
+
thresholds = [(unique[i] + unique[i + 1]) / 2 for i in range(len(unique) - 1)]
|
|
133
|
+
if len(thresholds) > self.max_thresholds:
|
|
134
|
+
step = len(thresholds) / self.max_thresholds
|
|
135
|
+
thresholds = [thresholds[int(i * step)] for i in range(self.max_thresholds)]
|
|
136
|
+
return [Split(feature=feature, threshold=threshold) for threshold in thresholds]
|
|
137
|
+
|
|
138
|
+
return [Split(feature=feature, category=value) for value in unique[:-1]]
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _partition(records: list[dict[str, Any]], split: Split) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
142
|
+
left_records = []
|
|
143
|
+
right_records = []
|
|
144
|
+
for record in records:
|
|
145
|
+
if split.go_left(record):
|
|
146
|
+
left_records.append(record)
|
|
147
|
+
else:
|
|
148
|
+
right_records.append(record)
|
|
149
|
+
return left_records, right_records
|
seqtrees/_utils.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def is_pandas_dataframe(value: Any) -> bool:
|
|
8
|
+
return hasattr(value, "columns") and hasattr(value, "to_dict") and hasattr(value, "__len__")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def normalize_table(data: Any) -> tuple[list[dict[str, Any]], list[str], bool]:
|
|
12
|
+
"""Return records, column names, and whether the input looked DataFrame-like."""
|
|
13
|
+
if is_pandas_dataframe(data):
|
|
14
|
+
columns = [str(column) for column in data.columns]
|
|
15
|
+
records = [{str(key): val for key, val in row.items()} for row in data.to_dict("records")]
|
|
16
|
+
return records, columns, True
|
|
17
|
+
|
|
18
|
+
if not isinstance(data, Sequence) or isinstance(data, (str, bytes)):
|
|
19
|
+
raise TypeError("X must be a pandas DataFrame, a list of dictionaries, or a list of rows.")
|
|
20
|
+
|
|
21
|
+
rows = list(data)
|
|
22
|
+
if not rows:
|
|
23
|
+
raise ValueError("X must contain at least one row.")
|
|
24
|
+
|
|
25
|
+
first = rows[0]
|
|
26
|
+
if isinstance(first, Mapping):
|
|
27
|
+
columns = [str(column) for column in first.keys()]
|
|
28
|
+
records = []
|
|
29
|
+
for row in rows:
|
|
30
|
+
if not isinstance(row, Mapping):
|
|
31
|
+
raise TypeError("All rows must use the same representation.")
|
|
32
|
+
records.append({str(key): value for key, value in row.items()})
|
|
33
|
+
return records, columns, False
|
|
34
|
+
|
|
35
|
+
if isinstance(first, Sequence) and not isinstance(first, (str, bytes)):
|
|
36
|
+
width = len(first)
|
|
37
|
+
columns = [f"x{i}" for i in range(width)]
|
|
38
|
+
records = []
|
|
39
|
+
for row in rows:
|
|
40
|
+
if not isinstance(row, Sequence) or isinstance(row, (str, bytes)) or len(row) != width:
|
|
41
|
+
raise TypeError("All rows must be sequences with the same length.")
|
|
42
|
+
records.append({columns[i]: value for i, value in enumerate(row)})
|
|
43
|
+
return records, columns, False
|
|
44
|
+
|
|
45
|
+
raise TypeError("Rows must be dictionaries or sequences.")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_columns(columns: list[str], requested: Sequence[str | int] | None) -> list[str]:
|
|
49
|
+
if requested is None:
|
|
50
|
+
return list(columns)
|
|
51
|
+
|
|
52
|
+
resolved = []
|
|
53
|
+
for item in requested:
|
|
54
|
+
if isinstance(item, int):
|
|
55
|
+
try:
|
|
56
|
+
resolved.append(columns[item])
|
|
57
|
+
except IndexError as exc:
|
|
58
|
+
raise ValueError(f"Column index out of range: {item}") from exc
|
|
59
|
+
else:
|
|
60
|
+
name = str(item)
|
|
61
|
+
if name not in columns:
|
|
62
|
+
raise ValueError(f"Unknown column in variable_order: {name!r}")
|
|
63
|
+
resolved.append(name)
|
|
64
|
+
|
|
65
|
+
if len(set(resolved)) != len(resolved):
|
|
66
|
+
raise ValueError("variable_order cannot contain duplicate columns.")
|
|
67
|
+
if set(resolved) != set(columns):
|
|
68
|
+
missing = sorted(set(columns) - set(resolved))
|
|
69
|
+
extra = sorted(set(resolved) - set(columns))
|
|
70
|
+
raise ValueError(f"variable_order must include every column exactly once; missing={missing}, extra={extra}")
|
|
71
|
+
return resolved
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_column_subset(columns: list[str], requested: Sequence[str | int] | None, *, parameter: str) -> list[str]:
|
|
75
|
+
if requested is None:
|
|
76
|
+
return list(columns)
|
|
77
|
+
|
|
78
|
+
resolved = []
|
|
79
|
+
for item in requested:
|
|
80
|
+
if isinstance(item, int):
|
|
81
|
+
try:
|
|
82
|
+
resolved.append(columns[item])
|
|
83
|
+
except IndexError as exc:
|
|
84
|
+
raise ValueError(f"Column index out of range in {parameter}: {item}") from exc
|
|
85
|
+
else:
|
|
86
|
+
name = str(item)
|
|
87
|
+
if name not in columns:
|
|
88
|
+
raise ValueError(f"Unknown column in {parameter}: {name!r}")
|
|
89
|
+
resolved.append(name)
|
|
90
|
+
|
|
91
|
+
if len(set(resolved)) != len(resolved):
|
|
92
|
+
raise ValueError(f"{parameter} cannot contain duplicate columns.")
|
|
93
|
+
return resolved
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def is_number(value: Any) -> bool:
|
|
97
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def is_integer_code(value: Any) -> bool:
|
|
101
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def is_float_value(value: Any) -> bool:
|
|
105
|
+
return isinstance(value, float) and not isinstance(value, bool)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def require_numeric_values(records: list[dict[str, Any]], columns: list[str], *, backend: str) -> None:
|
|
109
|
+
for column in columns:
|
|
110
|
+
for row_index, record in enumerate(records):
|
|
111
|
+
value = record.get(column)
|
|
112
|
+
if value is None:
|
|
113
|
+
continue
|
|
114
|
+
if not is_number(value):
|
|
115
|
+
raise TypeError(
|
|
116
|
+
f"tree_backend={backend!r} requires preprocessed numeric data; "
|
|
117
|
+
f"column {column!r} has non-numeric value {value!r} at row {row_index}."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def validate_no_nulls(records: list[dict[str, Any]], columns: list[str]) -> None:
|
|
122
|
+
for row_index, record in enumerate(records):
|
|
123
|
+
for column in columns:
|
|
124
|
+
if record.get(column) is None:
|
|
125
|
+
raise ValueError(f"SeqTrees does not accept null values; found null in column {column!r} at row {row_index}.")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def validate_variable_types(
|
|
129
|
+
records: list[dict[str, Any]],
|
|
130
|
+
*,
|
|
131
|
+
continuous_columns: set[str],
|
|
132
|
+
discrete_columns: set[str],
|
|
133
|
+
) -> None:
|
|
134
|
+
for column in continuous_columns:
|
|
135
|
+
for row_index, record in enumerate(records):
|
|
136
|
+
value = record[column]
|
|
137
|
+
if not is_float_value(value):
|
|
138
|
+
raise TypeError(
|
|
139
|
+
f"continuous column {column!r} must contain float values; "
|
|
140
|
+
f"found {value!r} at row {row_index}."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
for column in discrete_columns:
|
|
144
|
+
for row_index, record in enumerate(records):
|
|
145
|
+
value = record[column]
|
|
146
|
+
if not is_integer_code(value):
|
|
147
|
+
raise TypeError(
|
|
148
|
+
f"discrete column {column!r} must contain integer category codes; "
|
|
149
|
+
f"found {value!r} at row {row_index}."
|
|
150
|
+
)
|