splitpilot 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.
- splitpilot/__init__.py +7 -0
- splitpilot/core/__init__.py +1 -0
- splitpilot/core/pilot.py +67 -0
- splitpilot/core/profiler.py +207 -0
- splitpilot/core/recommender.py +127 -0
- splitpilot/core/splitter.py +441 -0
- splitpilot/models/__init__.py +1 -0
- splitpilot/models/recommendation.py +15 -0
- splitpilot-0.1.0.dist-info/METADATA +1531 -0
- splitpilot-0.1.0.dist-info/RECORD +12 -0
- splitpilot-0.1.0.dist-info/WHEEL +4 -0
- splitpilot-0.1.0.dist-info/licenses/LICENSE +21 -0
splitpilot/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core SplitPilot functionality."""
|
splitpilot/core/pilot.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from .profiler import DatasetProfiler
|
|
2
|
+
from .recommender import SplitRecommender
|
|
3
|
+
from .splitter import DatasetSplitter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Pilot:
|
|
7
|
+
"""
|
|
8
|
+
High-level interface for analyzing a dataset
|
|
9
|
+
and creating a leakage-aware train/test split.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, df, target):
|
|
13
|
+
self.df = df
|
|
14
|
+
self.target = target
|
|
15
|
+
|
|
16
|
+
self._profile = None
|
|
17
|
+
self._recommendation = None
|
|
18
|
+
|
|
19
|
+
def profile(self):
|
|
20
|
+
"""
|
|
21
|
+
Analyze the dataset.
|
|
22
|
+
"""
|
|
23
|
+
if self._profile is None:
|
|
24
|
+
self._profile = DatasetProfiler(
|
|
25
|
+
self.df,
|
|
26
|
+
target=self.target
|
|
27
|
+
).profile()
|
|
28
|
+
|
|
29
|
+
return self._profile
|
|
30
|
+
|
|
31
|
+
def recommend(self):
|
|
32
|
+
"""
|
|
33
|
+
Recommend an appropriate splitting strategy.
|
|
34
|
+
"""
|
|
35
|
+
if self._recommendation is None:
|
|
36
|
+
profile = self.profile()
|
|
37
|
+
|
|
38
|
+
self._recommendation = SplitRecommender().recommend(
|
|
39
|
+
profile
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return self._recommendation
|
|
43
|
+
|
|
44
|
+
def split(
|
|
45
|
+
self,
|
|
46
|
+
recommendation=None,
|
|
47
|
+
test_size=0.2,
|
|
48
|
+
random_state=42,
|
|
49
|
+
):
|
|
50
|
+
"""
|
|
51
|
+
Execute the recommended train/test split.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
if recommendation is None:
|
|
55
|
+
recommendation = self.recommend()
|
|
56
|
+
|
|
57
|
+
splitter = DatasetSplitter()
|
|
58
|
+
|
|
59
|
+
return splitter.split(
|
|
60
|
+
self.df,
|
|
61
|
+
target=self.target,
|
|
62
|
+
strategy=recommendation.strategy,
|
|
63
|
+
group_column=recommendation.group_column,
|
|
64
|
+
time_column=recommendation.time_column,
|
|
65
|
+
test_size=test_size,
|
|
66
|
+
random_state=random_state,
|
|
67
|
+
)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class GroupCandidate:
|
|
8
|
+
"""A column that may represent a meaningful group or entity."""
|
|
9
|
+
|
|
10
|
+
column: str
|
|
11
|
+
score: float
|
|
12
|
+
reasons: list[str]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class DatasetProfile:
|
|
17
|
+
"""Summary of the dataset relevant to split selection."""
|
|
18
|
+
|
|
19
|
+
rows: int
|
|
20
|
+
columns: int
|
|
21
|
+
target: str
|
|
22
|
+
target_type: str
|
|
23
|
+
target_unique_values: int
|
|
24
|
+
target_distribution: dict[str, float]
|
|
25
|
+
group_candidates: list[GroupCandidate]
|
|
26
|
+
possible_time_columns: list[str]
|
|
27
|
+
duplicate_rows: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DatasetProfiler:
|
|
31
|
+
"""Analyze a dataset before selecting an evaluation split."""
|
|
32
|
+
|
|
33
|
+
GROUP_NAME_HINTS = {
|
|
34
|
+
"id",
|
|
35
|
+
"customer",
|
|
36
|
+
"user",
|
|
37
|
+
"patient",
|
|
38
|
+
"account",
|
|
39
|
+
"member",
|
|
40
|
+
"client",
|
|
41
|
+
"session",
|
|
42
|
+
"device",
|
|
43
|
+
"store",
|
|
44
|
+
"merchant",
|
|
45
|
+
"household",
|
|
46
|
+
"group",
|
|
47
|
+
"entity",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def __init__(self, df: pd.DataFrame, target: str) -> None:
|
|
51
|
+
if not isinstance(df, pd.DataFrame):
|
|
52
|
+
raise TypeError("df must be a pandas DataFrame.")
|
|
53
|
+
|
|
54
|
+
if target not in df.columns:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"Target column '{target}' was not found in the dataset."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if df.empty:
|
|
60
|
+
raise ValueError("The dataset cannot be empty.")
|
|
61
|
+
|
|
62
|
+
self.df = df
|
|
63
|
+
self.target = target
|
|
64
|
+
|
|
65
|
+
def profile(self) -> DatasetProfile:
|
|
66
|
+
"""Create a structural profile of the dataset."""
|
|
67
|
+
|
|
68
|
+
target_series = self.df[self.target]
|
|
69
|
+
|
|
70
|
+
target_type = self._detect_target_type(target_series)
|
|
71
|
+
|
|
72
|
+
distribution = (
|
|
73
|
+
target_series.value_counts(normalize=True, dropna=False)
|
|
74
|
+
.round(4)
|
|
75
|
+
.to_dict()
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
distribution = {
|
|
79
|
+
str(key): float(value)
|
|
80
|
+
for key, value in distribution.items()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return DatasetProfile(
|
|
84
|
+
rows=len(self.df),
|
|
85
|
+
columns=len(self.df.columns),
|
|
86
|
+
target=self.target,
|
|
87
|
+
target_type=target_type,
|
|
88
|
+
target_unique_values=target_series.nunique(dropna=True),
|
|
89
|
+
target_distribution=distribution,
|
|
90
|
+
group_candidates=self._find_group_candidates(),
|
|
91
|
+
possible_time_columns=self._find_time_columns(),
|
|
92
|
+
duplicate_rows=int(self.df.duplicated().sum()),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def _detect_target_type(self, series: pd.Series) -> str:
|
|
96
|
+
"""Determine whether the target looks like classification or regression."""
|
|
97
|
+
|
|
98
|
+
unique_values = series.nunique(dropna=True)
|
|
99
|
+
|
|
100
|
+
if pd.api.types.is_numeric_dtype(series):
|
|
101
|
+
if unique_values <= 20:
|
|
102
|
+
return "classification"
|
|
103
|
+
|
|
104
|
+
return "regression"
|
|
105
|
+
|
|
106
|
+
return "classification"
|
|
107
|
+
|
|
108
|
+
def _find_group_candidates(self) -> list[GroupCandidate]:
|
|
109
|
+
"""Find and score columns that may represent groups or entities."""
|
|
110
|
+
|
|
111
|
+
candidates = []
|
|
112
|
+
|
|
113
|
+
for column in self.df.columns:
|
|
114
|
+
if column == self.target:
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
series = self.df[column]
|
|
118
|
+
|
|
119
|
+
if series.isna().all():
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
unique_count = series.nunique(dropna=True)
|
|
123
|
+
|
|
124
|
+
if unique_count <= 1:
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
score = 0.0
|
|
128
|
+
reasons = []
|
|
129
|
+
|
|
130
|
+
# Signal 1: column name looks like an identifier/entity column.
|
|
131
|
+
name_parts = (
|
|
132
|
+
column.lower()
|
|
133
|
+
.replace("-", "_")
|
|
134
|
+
.split("_")
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
name_matches = self.GROUP_NAME_HINTS.intersection(name_parts)
|
|
138
|
+
|
|
139
|
+
if name_matches:
|
|
140
|
+
score += 0.60
|
|
141
|
+
reasons.append(
|
|
142
|
+
"identifier/entity-like column name"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Signal 2: values repeat across multiple rows.
|
|
146
|
+
repetition_ratio = 1 - (unique_count / len(self.df))
|
|
147
|
+
|
|
148
|
+
if repetition_ratio > 0:
|
|
149
|
+
score += min(repetition_ratio * 0.50, 0.25)
|
|
150
|
+
reasons.append("repeated values detected")
|
|
151
|
+
|
|
152
|
+
# Signal 3: each group has multiple observations.
|
|
153
|
+
group_sizes = series.value_counts(dropna=True)
|
|
154
|
+
|
|
155
|
+
if len(group_sizes) > 0:
|
|
156
|
+
average_group_size = group_sizes.mean()
|
|
157
|
+
|
|
158
|
+
if average_group_size >= 2:
|
|
159
|
+
score += 0.15
|
|
160
|
+
reasons.append("multiple observations per group")
|
|
161
|
+
|
|
162
|
+
score = min(score, 1.0)
|
|
163
|
+
|
|
164
|
+
# Only expose candidates with meaningful evidence.
|
|
165
|
+
if score >= 0.50:
|
|
166
|
+
candidates.append(
|
|
167
|
+
GroupCandidate(
|
|
168
|
+
column=column,
|
|
169
|
+
score=round(score, 2),
|
|
170
|
+
reasons=reasons,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
candidates.sort(
|
|
175
|
+
key=lambda candidate: candidate.score,
|
|
176
|
+
reverse=True,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return candidates
|
|
180
|
+
|
|
181
|
+
def _find_time_columns(self) -> list[str]:
|
|
182
|
+
"""Find columns that appear to contain temporal information."""
|
|
183
|
+
|
|
184
|
+
candidates = []
|
|
185
|
+
|
|
186
|
+
for column in self.df.columns:
|
|
187
|
+
if column == self.target:
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
series = self.df[column]
|
|
191
|
+
|
|
192
|
+
if pd.api.types.is_datetime64_any_dtype(series):
|
|
193
|
+
candidates.append(column)
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
if pd.api.types.is_object_dtype(series):
|
|
197
|
+
parsed = pd.to_datetime(series, errors="coerce")
|
|
198
|
+
|
|
199
|
+
non_null = series.notna().sum()
|
|
200
|
+
|
|
201
|
+
if non_null > 0:
|
|
202
|
+
parse_ratio = parsed.notna().sum() / non_null
|
|
203
|
+
|
|
204
|
+
if parse_ratio >= 0.9:
|
|
205
|
+
candidates.append(column)
|
|
206
|
+
|
|
207
|
+
return candidates
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from .profiler import DatasetProfile
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class SplitRecommendation:
|
|
9
|
+
strategy: str
|
|
10
|
+
reason: str
|
|
11
|
+
group_column: Optional[str] = None
|
|
12
|
+
time_column: Optional[str] = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SplitRecommender:
|
|
16
|
+
"""Recommend a train/test splitting strategy from a DatasetProfile."""
|
|
17
|
+
|
|
18
|
+
def recommend(self, profile: DatasetProfile) -> SplitRecommendation:
|
|
19
|
+
|
|
20
|
+
has_group = bool(profile.group_candidates)
|
|
21
|
+
has_time = bool(profile.possible_time_columns)
|
|
22
|
+
|
|
23
|
+
group_column = (
|
|
24
|
+
profile.group_candidates[0].column
|
|
25
|
+
if has_group
|
|
26
|
+
else None
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
time_column = (
|
|
30
|
+
profile.possible_time_columns[0]
|
|
31
|
+
if has_time
|
|
32
|
+
else None
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------
|
|
36
|
+
# 1. GROUP + TIME
|
|
37
|
+
# ---------------------------------------------------------
|
|
38
|
+
if has_group and has_time:
|
|
39
|
+
|
|
40
|
+
return SplitRecommendation(
|
|
41
|
+
strategy="group_time",
|
|
42
|
+
reason=(
|
|
43
|
+
f"Repeated observations were detected for "
|
|
44
|
+
f"'{group_column}', and temporal information was "
|
|
45
|
+
f"detected in '{time_column}'. A split should respect "
|
|
46
|
+
"both entity boundaries and chronological ordering "
|
|
47
|
+
"to reduce leakage."
|
|
48
|
+
),
|
|
49
|
+
group_column=group_column,
|
|
50
|
+
time_column=time_column,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------
|
|
54
|
+
# 2. GROUP
|
|
55
|
+
# ---------------------------------------------------------
|
|
56
|
+
if has_group:
|
|
57
|
+
|
|
58
|
+
if profile.target_type == "classification":
|
|
59
|
+
strategy = "group_stratified"
|
|
60
|
+
else:
|
|
61
|
+
strategy = "group"
|
|
62
|
+
|
|
63
|
+
return SplitRecommendation(
|
|
64
|
+
strategy=strategy,
|
|
65
|
+
reason=(
|
|
66
|
+
f"Multiple observations were detected for "
|
|
67
|
+
f"'{group_column}'. A row-wise random split could "
|
|
68
|
+
"place observations from the same entity in both "
|
|
69
|
+
"train and test sets."
|
|
70
|
+
),
|
|
71
|
+
group_column=group_column,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------
|
|
75
|
+
# 3. TIME
|
|
76
|
+
# ---------------------------------------------------------
|
|
77
|
+
if has_time:
|
|
78
|
+
|
|
79
|
+
return SplitRecommendation(
|
|
80
|
+
strategy="time",
|
|
81
|
+
reason=(
|
|
82
|
+
f"Temporal information was detected in "
|
|
83
|
+
f"'{time_column}'. A chronological split can better "
|
|
84
|
+
"represent the real-world situation where future "
|
|
85
|
+
"observations are predicted from past observations."
|
|
86
|
+
),
|
|
87
|
+
time_column=time_column,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------
|
|
91
|
+
# 4. CLASSIFICATION
|
|
92
|
+
# ---------------------------------------------------------
|
|
93
|
+
if profile.target_type == "classification":
|
|
94
|
+
|
|
95
|
+
return SplitRecommendation(
|
|
96
|
+
strategy="stratified",
|
|
97
|
+
reason=(
|
|
98
|
+
"The target is categorical and no group or temporal "
|
|
99
|
+
"structure was detected. Stratification helps preserve "
|
|
100
|
+
"the target distribution across train and test sets."
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------
|
|
105
|
+
# 5. REGRESSION
|
|
106
|
+
# ---------------------------------------------------------
|
|
107
|
+
if profile.target_type == "regression":
|
|
108
|
+
|
|
109
|
+
return SplitRecommendation(
|
|
110
|
+
strategy="random",
|
|
111
|
+
reason=(
|
|
112
|
+
"The target is continuous and no group or temporal "
|
|
113
|
+
"structure was detected. A random split is therefore "
|
|
114
|
+
"a reasonable default."
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------
|
|
119
|
+
# 6. FALLBACK
|
|
120
|
+
# ---------------------------------------------------------
|
|
121
|
+
return SplitRecommendation(
|
|
122
|
+
strategy="random",
|
|
123
|
+
reason=(
|
|
124
|
+
"No specialized splitting structure was detected. "
|
|
125
|
+
"Falling back to a random split."
|
|
126
|
+
),
|
|
127
|
+
)
|