Skip to content

Commit cef6ff3

Browse files
committed
Add k-fold cross-validation split (closes #50)
New kfold() function splits data into k folds for cross-validation, with each fold containing train/ and val/ subdirectories. Defaults to symlinks to avoid k× disk usage. Also adds tests for symlink and format filtering features from merged PRs.
1 parent 857596a commit cef6ff3

4 files changed

Lines changed: 261 additions & 19 deletions

File tree

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ splitfolders.ratio("input_folder", output="output",
9191
# Set 3 values, e.g. `(300, 100, 100)`, to limit the number of training values.
9292
splitfolders.fixed("input_folder", output="output",
9393
seed=1337, fixed=(100, 100), oversample=False, group_prefix=None, formats=None, move=False) # default values
94+
95+
# Split into k folds for cross-validation.
96+
# Each fold directory contains train/ and val/ subdirectories.
97+
# Uses symlinks by default to avoid k× disk usage.
98+
splitfolders.kfold("input_folder", output="output",
99+
seed=1337, k=5, group_prefix=None, formats=None, move="symlink") # default values
94100
```
95101

96102
Occasionally, you may have things that comprise more than a single file (e.g. picture (.png) + annotation (.txt)).
@@ -109,13 +115,14 @@ Set
109115

110116
```
111117
Usage:
112-
splitfolders [--output] [--ratio] [--fixed] [--seed] [--oversample] [--group_prefix] [--formats] [--move] folder_with_images
118+
splitfolders [--output] [--ratio] [--fixed] [--kfold] [--seed] [--oversample] [--group_prefix] [--formats] [--move] folder_with_images
113119
Options:
114120
--output path to the output folder. defaults to `output`. Get created if non-existent.
115121
--ratio the ratio to split. e.g. for train/val/test `.8 .1 .1 --` or for train/val `.8 .2 --`.
116122
--fixed set the absolute number of items per validation/test set. The remaining items constitute
117123
the training set. e.g. for train/val/test `100 100` or for train/val `100`.
118124
Set 3 values, e.g. `300 100 100`, to limit the number of training values.
125+
--kfold split into k folds for cross-validation. e.g. `5` for 5-fold CV. Uses symlinks by default.
119126
--seed set seed value for shuffling the items. defaults to 1337.
120127
--oversample enable oversampling of imbalanced datasets, works only with --fixed.
121128
--group_prefix split files into equally-sized groups based on their prefix
@@ -124,6 +131,7 @@ Options:
124131
--symlink symlink(create shortcut) the files instead of copying
125132
Example:
126133
splitfolders --ratio .8 .1 .1 -- folder_with_images
134+
splitfolders --kfold 5 folder_with_images
127135
```
128136

129137
Because of some [Python quirks](https://github.com/jfilter/split-folders/issues/19) you have to prepend ` --` after using `--ratio`.

splitfolders/cli.py

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import argparse
22

3-
from .split import fixed, ratio
3+
from .split import fixed, kfold, ratio
44

55

66
def run():
@@ -38,6 +38,12 @@ def run():
3838
" Set 3 values, e.g. `300 100 100`, to limit the number of training values."
3939
),
4040
)
41+
parser.add_argument(
42+
"--kfold",
43+
type=int,
44+
default=None,
45+
help="split into k folds for cross-validation. e.g. `5` for 5-fold CV. Each fold has train/ and val/ subdirs.",
46+
)
4147
parser.add_argument(
4248
"--oversample",
4349
action="store_true",
@@ -83,20 +89,26 @@ def run():
8389

8490
if args.ratio:
8591
ratio(args.input, args.output, args.seed, args.ratio, args.group_prefix, args.move, args.formats)
92+
elif args.fixed:
93+
fixed(
94+
args.input,
95+
args.output,
96+
args.seed,
97+
args.fixed,
98+
args.oversample,
99+
args.group_prefix,
100+
args.move,
101+
args.formats,
102+
)
103+
elif args.kfold:
104+
kfold(
105+
args.input,
106+
args.output,
107+
args.seed,
108+
args.kfold,
109+
args.group_prefix,
110+
args.move if args.move else "symlink",
111+
args.formats,
112+
)
86113
else:
87-
if args.fixed:
88-
fixed(
89-
args.input,
90-
args.output,
91-
args.seed,
92-
args.fixed,
93-
args.oversample,
94-
args.group_prefix,
95-
args.move,
96-
args.formats,
97-
)
98-
else:
99-
print(
100-
"Please specify either your `--ratio` or your `--fixed` number of items"
101-
" for the split. see -h for more help."
102-
)
114+
print("Please specify either your `--ratio`, `--fixed`, or `--kfold` for the split. see -h for more help.")

splitfolders/split.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,49 @@ def fixed(
192192
shutil.copy2(str(f_orig), str(f_dest))
193193

194194

195+
def kfold(input, output="output", seed=1337, k=5, group_prefix=None, move="symlink", formats=None):
196+
if k < 2:
197+
raise ValueError("`k` must be 2 or greater.")
198+
199+
check_input_format(input)
200+
valid_extensions(formats)
201+
202+
if use_tqdm:
203+
prog_bar = tqdm(desc="Copying files", unit=" files")
204+
205+
for class_dir in list_dirs(input):
206+
split_class_dir_kfold(class_dir, output, k, seed, prog_bar if use_tqdm else None, group_prefix, move, formats)
207+
208+
if use_tqdm:
209+
prog_bar.close()
210+
211+
212+
def split_class_dir_kfold(class_dir, output, k, seed, prog_bar, group_prefix, move, formats):
213+
"""
214+
Splits a class folder into k folds for cross-validation.
215+
Each fold directory gets train/ and val/ subdirectories.
216+
"""
217+
files = setup_files(class_dir, seed, group_prefix, formats)
218+
219+
# Partition files into k roughly equal chunks
220+
fold_size = len(files) // k
221+
remainder = len(files) % k
222+
partitions = []
223+
idx = 0
224+
for i in range(k):
225+
size = fold_size + (1 if i < remainder else 0)
226+
partitions.append(files[idx : idx + size])
227+
idx += size
228+
229+
# For each fold, val = partition i, train = all other partitions
230+
for i in range(k):
231+
val_files = partitions[i]
232+
train_files = [f for j, part in enumerate(partitions) if j != i for f in part]
233+
fold_output = str(Path(output) / f"fold_{i + 1}")
234+
li = [(train_files, "train"), (val_files, "val")]
235+
copy_files(li, class_dir, fold_output, prog_bar, move)
236+
237+
195238
def group_by_prefix(files, len_pairs):
196239
"""
197240
Split files into groups of len `len_pairs` based on their prefix.

tests/test_split.py

Lines changed: 180 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import pytest
66

7-
from splitfolders import fixed, ratio
7+
from splitfolders import fixed, kfold, ratio
88

99

1010
def test_second_package():
@@ -251,6 +251,95 @@ def test_split_fixed_oversample_prefix():
251251
assert a != b
252252

253253

254+
def test_split_ratio_symlink():
255+
input_dir = os.path.join(os.path.dirname(__file__), "imgs")
256+
output_dir = os.path.join(os.path.dirname(__file__), "output")
257+
258+
shutil.rmtree(output_dir, ignore_errors=True)
259+
260+
ratio(input_dir, output_dir, move="symlink")
261+
262+
# ensure the number of pics is the same
263+
a = len(list(pathlib.Path(input_dir).glob("**/*.jpg")))
264+
b = len(list(pathlib.Path(output_dir).glob("**/*.jpg")))
265+
assert a == b
266+
267+
# ensure all output files are symlinks
268+
for f in pathlib.Path(output_dir).rglob("*.jpg"):
269+
assert f.is_symlink()
270+
271+
272+
def test_split_fixed_symlink():
273+
input_dir = os.path.join(os.path.dirname(__file__), "imgs")
274+
output_dir = os.path.join(os.path.dirname(__file__), "output")
275+
276+
shutil.rmtree(output_dir, ignore_errors=True)
277+
278+
fixed(input_dir, output_dir, fixed=(2, 2), move="symlink")
279+
280+
# ensure the number of pics is the same
281+
a = len(list(pathlib.Path(input_dir).glob("**/*.jpg")))
282+
b = len(list(pathlib.Path(output_dir).glob("**/*.jpg")))
283+
assert a == b
284+
285+
# ensure all output files are symlinks
286+
for f in pathlib.Path(output_dir).rglob("*.jpg"):
287+
assert f.is_symlink()
288+
289+
290+
def test_split_ratio_formats():
291+
input_dir = os.path.join(os.path.dirname(__file__), "imgs_texts")
292+
output_dir = os.path.join(os.path.dirname(__file__), "output")
293+
294+
shutil.rmtree(output_dir, ignore_errors=True)
295+
296+
ratio(input_dir, output_dir, formats=[".jpg"])
297+
298+
# only jpg files should be in output
299+
jpg_count = len(list(pathlib.Path(output_dir).glob("**/*.jpg")))
300+
txt_count = len(list(pathlib.Path(output_dir).glob("**/*.txt")))
301+
assert jpg_count > 0
302+
assert txt_count == 0
303+
304+
305+
def test_split_fixed_formats():
306+
input_dir = os.path.join(os.path.dirname(__file__), "imgs_texts")
307+
output_dir = os.path.join(os.path.dirname(__file__), "output")
308+
309+
shutil.rmtree(output_dir, ignore_errors=True)
310+
311+
fixed(input_dir, output_dir, fixed=(1, 1), formats=[".txt"])
312+
313+
# only txt files should be in output
314+
jpg_count = len(list(pathlib.Path(output_dir).glob("**/*.jpg")))
315+
txt_count = len(list(pathlib.Path(output_dir).glob("**/*.txt")))
316+
assert txt_count > 0
317+
assert jpg_count == 0
318+
319+
320+
def test_split_ratio_formats_multiple():
321+
input_dir = os.path.join(os.path.dirname(__file__), "imgs_texts")
322+
output_dir = os.path.join(os.path.dirname(__file__), "output")
323+
324+
shutil.rmtree(output_dir, ignore_errors=True)
325+
326+
ratio(input_dir, output_dir, formats=[".jpg", ".txt"])
327+
328+
# both jpg and txt files should be in output
329+
jpg_count = len(list(pathlib.Path(output_dir).glob("**/*.jpg")))
330+
txt_count = len(list(pathlib.Path(output_dir).glob("**/*.txt")))
331+
assert jpg_count > 0
332+
assert txt_count > 0
333+
334+
335+
def test_invalid_formats():
336+
input_dir = os.path.join(os.path.dirname(__file__), "imgs")
337+
output_dir = os.path.join(os.path.dirname(__file__), "output")
338+
339+
with pytest.raises(ValueError):
340+
ratio(input_dir, output_dir, formats=["jpg"])
341+
342+
254343
def test_split_ratio_prefix_error_1():
255344
input_dir = os.path.join(os.path.dirname(__file__), "imgs_texts_error_1")
256345
output_dir = os.path.join(os.path.dirname(__file__), "output")
@@ -269,3 +358,93 @@ def test_split_ratio_prefix_error_2():
269358

270359
with pytest.raises(ValueError):
271360
ratio(input_dir, output_dir, group_prefix=2)
361+
362+
363+
def test_kfold_basic():
364+
input_dir = os.path.join(os.path.dirname(__file__), "imgs")
365+
output_dir = os.path.join(os.path.dirname(__file__), "output")
366+
367+
shutil.rmtree(output_dir, ignore_errors=True)
368+
369+
kfold(input_dir, output_dir, k=5, move=False)
370+
371+
# verify 5 fold directories created
372+
fold_dirs = sorted(pathlib.Path(output_dir).iterdir())
373+
assert len(fold_dirs) == 5
374+
375+
input_count = len(list(pathlib.Path(input_dir).glob("**/*.jpg")))
376+
377+
for fold_dir in fold_dirs:
378+
# each fold should have train/ and val/
379+
assert (fold_dir / "train").is_dir()
380+
assert (fold_dir / "val").is_dir()
381+
382+
# train + val should equal input count
383+
train_count = len(list((fold_dir / "train").rglob("*.jpg")))
384+
val_count = len(list((fold_dir / "val").rglob("*.jpg")))
385+
assert train_count + val_count == input_count
386+
387+
# no overlap between train and val
388+
train_files = {f.name for f in (fold_dir / "train").rglob("*.jpg")}
389+
val_files = {f.name for f in (fold_dir / "val").rglob("*.jpg")}
390+
assert train_files.isdisjoint(val_files)
391+
392+
393+
def test_kfold_3():
394+
input_dir = os.path.join(os.path.dirname(__file__), "imgs")
395+
output_dir = os.path.join(os.path.dirname(__file__), "output")
396+
397+
shutil.rmtree(output_dir, ignore_errors=True)
398+
399+
kfold(input_dir, output_dir, k=3, move=False)
400+
401+
fold_dirs = sorted(pathlib.Path(output_dir).iterdir())
402+
assert len(fold_dirs) == 3
403+
404+
405+
def test_kfold_symlink():
406+
input_dir = os.path.join(os.path.dirname(__file__), "imgs")
407+
output_dir = os.path.join(os.path.dirname(__file__), "output")
408+
409+
shutil.rmtree(output_dir, ignore_errors=True)
410+
411+
kfold(input_dir, output_dir, k=3)
412+
413+
# default is symlink
414+
for f in pathlib.Path(output_dir).rglob("*.jpg"):
415+
assert f.is_symlink()
416+
417+
418+
def test_kfold_copy():
419+
input_dir = os.path.join(os.path.dirname(__file__), "imgs")
420+
output_dir = os.path.join(os.path.dirname(__file__), "output")
421+
422+
shutil.rmtree(output_dir, ignore_errors=True)
423+
424+
kfold(input_dir, output_dir, k=3, move=False)
425+
426+
# move=False means real copies, not symlinks
427+
for f in pathlib.Path(output_dir).rglob("*.jpg"):
428+
assert not f.is_symlink()
429+
430+
431+
def test_kfold_formats():
432+
input_dir = os.path.join(os.path.dirname(__file__), "imgs_texts")
433+
output_dir = os.path.join(os.path.dirname(__file__), "output")
434+
435+
shutil.rmtree(output_dir, ignore_errors=True)
436+
437+
kfold(input_dir, output_dir, k=3, move=False, formats=[".jpg"])
438+
439+
jpg_count = len(list(pathlib.Path(output_dir).rglob("*.jpg")))
440+
txt_count = len(list(pathlib.Path(output_dir).rglob("*.txt")))
441+
assert jpg_count > 0
442+
assert txt_count == 0
443+
444+
445+
def test_kfold_invalid():
446+
input_dir = os.path.join(os.path.dirname(__file__), "imgs")
447+
output_dir = os.path.join(os.path.dirname(__file__), "output")
448+
449+
with pytest.raises(ValueError):
450+
kfold(input_dir, output_dir, k=1)

0 commit comments

Comments
 (0)