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.
@@ -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]