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 ADDED
@@ -0,0 +1,6 @@
1
+ """Sequential tree models for tabular synthetic data generation."""
2
+
3
+ from .synthesizer import SequentialTreeSynthesizer
4
+
5
+ __all__ = ["SequentialTreeSynthesizer"]
6
+ __version__ = "0.1.0"
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
+ )