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
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from sklearn.model_selection import train_test_split
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class SplitResult:
|
|
9
|
+
X_train: pd.DataFrame
|
|
10
|
+
X_test: pd.DataFrame
|
|
11
|
+
y_train: pd.Series
|
|
12
|
+
y_test: pd.Series
|
|
13
|
+
strategy: str
|
|
14
|
+
|
|
15
|
+
def __iter__(self):
|
|
16
|
+
yield self.X_train
|
|
17
|
+
yield self.X_test
|
|
18
|
+
yield self.y_train
|
|
19
|
+
yield self.y_test
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DatasetSplitter:
|
|
23
|
+
"""
|
|
24
|
+
Execute a train/test split using a recommended strategy.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def split(
|
|
28
|
+
self,
|
|
29
|
+
df: pd.DataFrame,
|
|
30
|
+
target: str,
|
|
31
|
+
strategy: str = "random",
|
|
32
|
+
group_column: str | None = None,
|
|
33
|
+
time_column: str | None = None,
|
|
34
|
+
test_size: float = 0.2,
|
|
35
|
+
random_state: int = 42,
|
|
36
|
+
) -> SplitResult:
|
|
37
|
+
|
|
38
|
+
if target not in df.columns:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"Target column '{target}' was not found in the dataset."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if not 0 < test_size < 1:
|
|
44
|
+
raise ValueError(
|
|
45
|
+
"test_size must be between 0 and 1."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if group_column is not None and group_column not in df.columns:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"Group column '{group_column}' was not found in the dataset."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if time_column is not None and time_column not in df.columns:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"Time column '{time_column}' was not found in the dataset."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
X = df.drop(columns=[target])
|
|
59
|
+
y = df[target]
|
|
60
|
+
|
|
61
|
+
if strategy == "random":
|
|
62
|
+
return self._random_split(
|
|
63
|
+
X,
|
|
64
|
+
y,
|
|
65
|
+
test_size,
|
|
66
|
+
random_state,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if strategy == "group":
|
|
70
|
+
return self._group_split(
|
|
71
|
+
df,
|
|
72
|
+
target,
|
|
73
|
+
group_column,
|
|
74
|
+
test_size,
|
|
75
|
+
random_state,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if strategy == "group_stratified":
|
|
79
|
+
return self._group_stratified_split(
|
|
80
|
+
df,
|
|
81
|
+
target,
|
|
82
|
+
group_column,
|
|
83
|
+
test_size,
|
|
84
|
+
random_state,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if strategy == "time":
|
|
88
|
+
return self._time_split(
|
|
89
|
+
df,
|
|
90
|
+
target,
|
|
91
|
+
time_column,
|
|
92
|
+
test_size,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if strategy == "group_time":
|
|
96
|
+
return self._group_time_split(
|
|
97
|
+
df,
|
|
98
|
+
target,
|
|
99
|
+
group_column,
|
|
100
|
+
time_column,
|
|
101
|
+
test_size,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"Unknown split strategy: '{strategy}'."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def _random_split(
|
|
109
|
+
self,
|
|
110
|
+
X,
|
|
111
|
+
y,
|
|
112
|
+
test_size,
|
|
113
|
+
random_state,
|
|
114
|
+
):
|
|
115
|
+
X_train, X_test, y_train, y_test = train_test_split(
|
|
116
|
+
X,
|
|
117
|
+
y,
|
|
118
|
+
test_size=test_size,
|
|
119
|
+
random_state=random_state,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return SplitResult(
|
|
123
|
+
X_train=X_train,
|
|
124
|
+
X_test=X_test,
|
|
125
|
+
y_train=y_train,
|
|
126
|
+
y_test=y_test,
|
|
127
|
+
strategy="random",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def _group_split(
|
|
131
|
+
self,
|
|
132
|
+
df,
|
|
133
|
+
target,
|
|
134
|
+
group_column,
|
|
135
|
+
test_size,
|
|
136
|
+
random_state,
|
|
137
|
+
):
|
|
138
|
+
if group_column is None:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
"group_column is required for group splitting."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
groups = df[group_column].drop_duplicates()
|
|
144
|
+
|
|
145
|
+
rng = pd.Series(groups).sample(
|
|
146
|
+
frac=1,
|
|
147
|
+
random_state=random_state,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
test_count = max(
|
|
151
|
+
1,
|
|
152
|
+
round(len(groups) * test_size),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
test_groups = set(
|
|
156
|
+
rng.iloc[:test_count]
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
train_groups = set(groups) - test_groups
|
|
160
|
+
|
|
161
|
+
train_mask = df[group_column].isin(train_groups)
|
|
162
|
+
test_mask = df[group_column].isin(test_groups)
|
|
163
|
+
|
|
164
|
+
train_df = df.loc[train_mask]
|
|
165
|
+
test_df = df.loc[test_mask]
|
|
166
|
+
|
|
167
|
+
return SplitResult(
|
|
168
|
+
X_train=train_df.drop(columns=[target]),
|
|
169
|
+
X_test=test_df.drop(columns=[target]),
|
|
170
|
+
y_train=train_df[target],
|
|
171
|
+
y_test=test_df[target],
|
|
172
|
+
strategy="group",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def _group_stratified_split(
|
|
176
|
+
self,
|
|
177
|
+
df,
|
|
178
|
+
target,
|
|
179
|
+
group_column,
|
|
180
|
+
test_size,
|
|
181
|
+
random_state,
|
|
182
|
+
):
|
|
183
|
+
if group_column is None:
|
|
184
|
+
raise ValueError(
|
|
185
|
+
"group_column is required for group_stratified splitting."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
group_target = (
|
|
189
|
+
df.groupby(group_column)[target]
|
|
190
|
+
.mean()
|
|
191
|
+
.reset_index()
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
group_target["_stratum"] = (
|
|
195
|
+
group_target[target]
|
|
196
|
+
>= group_target[target].median()
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
train_groups, test_groups = train_test_split(
|
|
200
|
+
group_target[group_column],
|
|
201
|
+
test_size=test_size,
|
|
202
|
+
random_state=random_state,
|
|
203
|
+
stratify=group_target["_stratum"],
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
train_groups = set(train_groups)
|
|
207
|
+
test_groups = set(test_groups)
|
|
208
|
+
|
|
209
|
+
train_mask = df[group_column].isin(train_groups)
|
|
210
|
+
test_mask = df[group_column].isin(test_groups)
|
|
211
|
+
|
|
212
|
+
train_df = df.loc[train_mask]
|
|
213
|
+
test_df = df.loc[test_mask]
|
|
214
|
+
|
|
215
|
+
return SplitResult(
|
|
216
|
+
X_train=train_df.drop(columns=[target]),
|
|
217
|
+
X_test=test_df.drop(columns=[target]),
|
|
218
|
+
y_train=train_df[target],
|
|
219
|
+
y_test=test_df[target],
|
|
220
|
+
strategy="group_stratified",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def _time_split(
|
|
224
|
+
self,
|
|
225
|
+
df,
|
|
226
|
+
target,
|
|
227
|
+
time_column,
|
|
228
|
+
test_size,
|
|
229
|
+
):
|
|
230
|
+
if time_column is None:
|
|
231
|
+
raise ValueError(
|
|
232
|
+
"time_column is required for time splitting."
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
ordered = df.copy()
|
|
236
|
+
|
|
237
|
+
ordered[time_column] = pd.to_datetime(
|
|
238
|
+
ordered[time_column],
|
|
239
|
+
errors="coerce",
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if ordered[time_column].isna().any():
|
|
243
|
+
raise ValueError(
|
|
244
|
+
f"Column '{time_column}' contains invalid dates."
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
ordered = ordered.sort_values(time_column)
|
|
248
|
+
|
|
249
|
+
split_index = int(
|
|
250
|
+
len(ordered) * (1 - test_size)
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
train_df = ordered.iloc[:split_index]
|
|
254
|
+
test_df = ordered.iloc[split_index:]
|
|
255
|
+
|
|
256
|
+
return SplitResult(
|
|
257
|
+
X_train=train_df.drop(columns=[target]),
|
|
258
|
+
X_test=test_df.drop(columns=[target]),
|
|
259
|
+
y_train=train_df[target],
|
|
260
|
+
y_test=test_df[target],
|
|
261
|
+
strategy="time",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def _group_time_split(
|
|
265
|
+
self,
|
|
266
|
+
df,
|
|
267
|
+
target,
|
|
268
|
+
group_column,
|
|
269
|
+
time_column,
|
|
270
|
+
test_size,
|
|
271
|
+
):
|
|
272
|
+
"""
|
|
273
|
+
Perform a leakage-safe chronological split while keeping
|
|
274
|
+
every group entirely inside either train or test.
|
|
275
|
+
|
|
276
|
+
A valid split must satisfy both:
|
|
277
|
+
|
|
278
|
+
1. No group appears in both train and test.
|
|
279
|
+
2. Every training observation occurs before every test observation.
|
|
280
|
+
|
|
281
|
+
Groups whose time ranges cross the selected temporal boundary
|
|
282
|
+
are excluded from the split rather than being divided between
|
|
283
|
+
train and test.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
if group_column is None:
|
|
287
|
+
raise ValueError(
|
|
288
|
+
"group_column is required for group_time splitting."
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if time_column is None:
|
|
292
|
+
raise ValueError(
|
|
293
|
+
"time_column is required for group_time splitting."
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
ordered = df.copy()
|
|
297
|
+
|
|
298
|
+
ordered[time_column] = pd.to_datetime(
|
|
299
|
+
ordered[time_column],
|
|
300
|
+
errors="coerce",
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
if ordered[time_column].isna().any():
|
|
304
|
+
raise ValueError(
|
|
305
|
+
f"Column '{time_column}' contains invalid dates."
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
group_ranges = (
|
|
309
|
+
ordered.groupby(group_column)[time_column]
|
|
310
|
+
.agg(["min", "max"])
|
|
311
|
+
.sort_values("max")
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
total_groups = len(group_ranges)
|
|
315
|
+
|
|
316
|
+
if total_groups < 2:
|
|
317
|
+
raise ValueError(
|
|
318
|
+
"group_time splitting requires at least two groups."
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
desired_test_groups = max(
|
|
322
|
+
1,
|
|
323
|
+
round(total_groups * test_size),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
best_split = None
|
|
327
|
+
|
|
328
|
+
for boundary_index in range(
|
|
329
|
+
1,
|
|
330
|
+
total_groups,
|
|
331
|
+
):
|
|
332
|
+
boundary_date = group_ranges["max"].iloc[
|
|
333
|
+
boundary_index - 1
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
train_groups = set(
|
|
337
|
+
group_ranges.index[
|
|
338
|
+
group_ranges["max"] <= boundary_date
|
|
339
|
+
]
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
test_groups = set(
|
|
343
|
+
group_ranges.index[
|
|
344
|
+
group_ranges["min"] > boundary_date
|
|
345
|
+
]
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
if not train_groups or not test_groups:
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
train_max = ordered.loc[
|
|
352
|
+
ordered[group_column].isin(train_groups),
|
|
353
|
+
time_column,
|
|
354
|
+
].max()
|
|
355
|
+
|
|
356
|
+
test_min = ordered.loc[
|
|
357
|
+
ordered[group_column].isin(test_groups),
|
|
358
|
+
time_column,
|
|
359
|
+
].min()
|
|
360
|
+
|
|
361
|
+
if train_max >= test_min:
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
test_group_difference = abs(
|
|
365
|
+
len(test_groups) - desired_test_groups
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
train_group_difference = abs(
|
|
369
|
+
len(train_groups)
|
|
370
|
+
- (total_groups - desired_test_groups)
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
score = (
|
|
374
|
+
test_group_difference,
|
|
375
|
+
train_group_difference,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
if (
|
|
379
|
+
best_split is None
|
|
380
|
+
or score < best_split["score"]
|
|
381
|
+
):
|
|
382
|
+
best_split = {
|
|
383
|
+
"score": score,
|
|
384
|
+
"train_groups": train_groups,
|
|
385
|
+
"test_groups": test_groups,
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if best_split is None:
|
|
389
|
+
raise ValueError(
|
|
390
|
+
"Could not create a leakage-safe group_time split. "
|
|
391
|
+
"The groups have overlapping time ranges, so no "
|
|
392
|
+
"strict chronological boundary exists."
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
train_groups = best_split["train_groups"]
|
|
396
|
+
test_groups = best_split["test_groups"]
|
|
397
|
+
|
|
398
|
+
train_mask = ordered[group_column].isin(
|
|
399
|
+
train_groups
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
test_mask = ordered[group_column].isin(
|
|
403
|
+
test_groups
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
train_df = ordered.loc[train_mask]
|
|
407
|
+
test_df = ordered.loc[test_mask]
|
|
408
|
+
|
|
409
|
+
if train_df.empty or test_df.empty:
|
|
410
|
+
raise RuntimeError(
|
|
411
|
+
"group_time split produced an empty train or test set."
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
train_ids = set(
|
|
415
|
+
train_df[group_column]
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
test_ids = set(
|
|
419
|
+
test_df[group_column]
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
if train_ids.intersection(test_ids):
|
|
423
|
+
raise RuntimeError(
|
|
424
|
+
"group_time split produced overlapping groups."
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
train_max = train_df[time_column].max()
|
|
428
|
+
test_min = test_df[time_column].min()
|
|
429
|
+
|
|
430
|
+
if train_max >= test_min:
|
|
431
|
+
raise RuntimeError(
|
|
432
|
+
"group_time split failed chronological validation."
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
return SplitResult(
|
|
436
|
+
X_train=train_df.drop(columns=[target]),
|
|
437
|
+
X_test=test_df.drop(columns=[target]),
|
|
438
|
+
y_train=train_df[target],
|
|
439
|
+
y_test=test_df[target],
|
|
440
|
+
strategy="group_time",
|
|
441
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Data models used by SplitPilot."""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class SplitRecommendation:
|
|
6
|
+
"""A recommended evaluation split."""
|
|
7
|
+
|
|
8
|
+
strategy: str
|
|
9
|
+
target: str
|
|
10
|
+
test_size: float
|
|
11
|
+
random_state: int | None
|
|
12
|
+
group_column: str | None
|
|
13
|
+
time_column: str | None
|
|
14
|
+
confidence: str
|
|
15
|
+
reasons: list[str]
|