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 ADDED
@@ -0,0 +1,7 @@
1
+ from .core.pilot import Pilot
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = [
6
+ "Pilot",
7
+ ]
@@ -0,0 +1 @@
1
+ """Core SplitPilot functionality."""
@@ -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
+ )