Repository: JuliaML/MLUtils.jl
Pull request #9 (2021-12-29 08:53:20)

add eachobs, eachbatch, kfolds,  leavepout
Changes:
- the implementation of `eachobs` and `eachbatch` is based on generators and doesn't rely on `BufferGetObs` (which I didn't port)
- `eachobs` doesn't rely on `ObsView`
- `eachobs` and `eachbatch` have a new `buffer` keyword arg
- `leaveout` is renamed to `leavepout`
- `kfolds` and `leavepout` return generators instead of `FoldView` (which I didn't port).  

Generally, I'm trying to go in the direction of #8 and have much less container/view types


Patch:
diff --git a/src/MLUtils.jl b/src/MLUtils.jl
index d0ad21f..b3bda61 100644
--- a/src/MLUtils.jl
+++ b/src/MLUtils.jl
@@ -24,5 +24,12 @@ export DataView,
        batchview, BatchView, 
        batchsize
 
+include("dataiterator.jl")
+export eachobs, 
+       eachbatch
+
+include("folds.jl")
+export kfolds,
+       leavepout
 
 end
diff --git a/src/dataiterator.jl b/src/dataiterator.jl
new file mode 100644
index 0000000..6319ad7
--- /dev/null
+++ b/src/dataiterator.jl
@@ -0,0 +1,79 @@
+"""
+    eachobs(data, buffer=false)
+
+Iterate over `data` one observation at a time. 
+
+If  `buffer=true` and supported by the type of `data`, 
+a buffer will be preallocated and reused for memory efficiency.
+A buffer object `b` can also be passed directly with `buffer=b`.
+
+```julia
+X = rand(4,100)
+for x in eachobs(X)
+    # loop entered 100 times
+    @assert typeof(x) <: Vector{Float64}
+    @assert size(x) == (4,)
+end
+```
+
+Multiple variables are supported (e.g. for labeled data)
+
+```julia
+for (x,y) in eachobs((X,Y))
+    # ...
+end
+```
+
+See [`eachbatch`](@ref) for a mini-batch version.
+"""
+function eachobs(data; buffer=false)
+    if buffer === false 
+        return (getobs(data, i) for i in 1:numobs(data))
+    else
+        if buffer === true && numobs(data) > 0
+            buffer = getobs(data, 1)
+        end
+        return (getobs!(buffer, data, i) for i in 1:numobs(data))
+    end
+end
+
+
+# --------------------------------------------------------------------
+
+"""
+    eachbatch(data; size=1, partial=true, buffer=false)
+    
+Iterate over `data` one batch at a time. If supported by the type
+of `data`, a buffer will be preallocated and reused for memory
+efficiency.
+
+
+The  batch-size can be either provided directly using
+`size`. 
+
+```julia
+X = rand(4,150)
+for x in eachbatch(X, size=10)
+    # loop entered 15 times
+    @assert typeof(x) <: Matrix{Float64}
+    @assert size(x) == (4,10)
+end
+```
+
+Multiple variables are supported (e.g. for labeled data):
+
+```julia
+for (x,y) in eachbatch((X,Y))
+    # ...
+end
+```
+
+See also [`batchview`](@ref) and [`eachobs`](@ref).
+"""
+function eachbatch(data; buffer=false, kws...)
+    batched = BatchView(data; kws...)
+    return eachobs(batched; buffer)
+end
+
+eachbatch(data, size; kws...) = eachbatch(data; size, kws...)
+
diff --git a/src/datasubset.jl b/src/datasubset.jl
index 9bf4ac2..4326d92 100644
--- a/src/datasubset.jl
+++ b/src/datasubset.jl
@@ -140,7 +140,7 @@ see also
 [`splitobs`](@ref), [`shuffleobs`](@ref),
 [`KFolds`](@ref), [`BatchView`](@ref), [`ObsView`](@ref),
 """
-struct DataSubset{T, I<:Union{Int,AbstractVector}} <: AbstractDataContainer
+struct DataSubset{T, I<:Union{Int,AbstractVector}}
     data::T
     indices::I
 
@@ -185,11 +185,14 @@ function Base.:(==)(s1::DataSubset, s2::DataSubset)
     s1.data == s2.data && all(i1==i2 for (i1,i2) in zip(s1.indices,s2.indices))
 end
 
-# override AbstractDataContainer defaults
+Base.length(subset::DataSubset) = length(subset.indices)
+
+Base.lastindex(subset::DataSubset) = length(subset)
+
 Base.getindex(subset::DataSubset, idx) =
     DataSubset(subset.data, _view(subset.indices, idx))
 
-numobs(subset::DataSubset) = length(subset.indices)
+numobs(subset::DataSubset) = length(subset)
 
 getobs(subset::DataSubset) = getobs(subset.data, subset.indices)
 
diff --git a/src/dataview.jl b/src/dataview.jl
index 289f9cc..a3364c2 100644
--- a/src/dataview.jl
+++ b/src/dataview.jl
@@ -204,7 +204,7 @@ end
 for (x,y) in batchview((X,Y), size = 20, partial=false)
     @assert typeof(x) <: SubArray{Float64,2}
     @assert typeof(y) <: SubArray{String,1}
-    @assert numobs(x) === numobs(y) === 20
+    @assert numobs(x) == numobs(y) == 20
 end
 
 
diff --git a/src/folds.jl b/src/folds.jl
new file mode 100644
index 0000000..8a008a4
--- /dev/null
+++ b/src/folds.jl
@@ -0,0 +1,190 @@
+
+"""
+    kfolds(n::Integer, k = 5) -> Tuple
+
+Compute the train/validation assignments for `k` repartitions of
+`n` observations, and return them in the form of two vectors. The
+first vector contains the index-vectors for the training subsets,
+and the second vector the index-vectors for the validation subsets
+respectively. A general rule of thumb is to use either `k = 5` or
+`k = 10`. The following code snippet generates the indices
+assignments for `k = 5`
+
+```julia
+julia> train_idx, val_idx = kfolds(10, 5);
+```
+
+Each observation is assigned to the validation subset once (and
+only once). Thus, a union over all validation index-vectors
+reproduces the full range `1:n`. Note that there is no random
+assignment of observations to subsets, which means that adjacent
+observations are likely to be part of the same validation subset.
+
+```julia
+julia> train_idx
+5-element Array{Array{Int64,1},1}:
+ [3,4,5,6,7,8,9,10]
+ [1,2,5,6,7,8,9,10]
+ [1,2,3,4,7,8,9,10]
+ [1,2,3,4,5,6,9,10]
+ [1,2,3,4,5,6,7,8]
+
+julia> val_idx
+5-element Array{UnitRange{Int64},1}:
+ 1:2
+ 3:4
+ 5:6
+ 7:8
+ 9:10
+```
+"""
+function kfolds(n::Integer, k::Integer = 5)
+    2 <= k <= n || throw(ArgumentError("n must be positive and k must to be within 2:$(max(2,n))"))
+    # Compute the size of each fold. This is important because
+    # in general the number of total observations might not be
+    # divideable by k. In such cases it is custom that the remaining
+    # observations are divided among the folds. Thus some folds
+    # have one more observation than others.
+    sizes = fill(floor(Int, n/k), k)
+    for i = 1:(n % k)
+        sizes[i] = sizes[i] + 1
+    end
+    # Compute start offset for each fold
+    offsets = cumsum(sizes) .- sizes .+ 1
+    # Compute the validation indices using the offsets and sizes
+    val_indices = map((o,s)->(o:o+s-1), offsets, sizes)
+    # The train indices are then the indicies not in validation
+    train_indices = map(idx->setdiff(1:n,idx), val_indices)
+    # We return a tuple of arrays
+    train_indices, val_indices
+end
+
+"""
+    kfolds(data, [k = 5])
+
+Repartition a `data` container `k` times using a `k` folds
+strategy and return the sequence of folds as a lazy iterator. 
+Only data subsets are created, which means that no actual data is copied until
+[`getobs`](@ref) is invoked.
+
+Conceptually, a k-folds repartitioning strategy divides the given
+`data` into `k` roughly equal-sized parts. Each part will serve
+as validation set once, while the remaining parts are used for
+training. This results in `k` different partitions of `data`.
+
+In the case that the size of the dataset is not dividable by the
+specified `k`, the remaining observations will be evenly
+distributed among the parts.
+
+```julia
+for (x_train, x_val) in kfolds(X, k=10)
+    # code called 10 times
+    # nobs(x_val) may differ up to ±1 over iterations
+end
+```
+
+Multiple variables are supported (e.g. for labeled data)
+
+```julia
+for ((x_train, y_train), val) in kfolds((X, Y), k=10)
+    # ...
+end
+```
+
+By default the folds are created using static splits. Use
+[`shuffleobs`](@ref) to randomly assign observations to the
+folds.
+
+```julia
+for (x_train, x_val) in kfolds(shuffleobs(X), k = 10)
+    # ...
+end
+```
+
+See [`leaveout`](@ref) for a related function.
+"""
+function kfolds(data, k::Integer)
+    n = numobs(data)
+    train_indices, val_indices = kfolds(n, k)
+
+    ((datasubset(data, itrain), datasubset(data, ival)) 
+        for (itrain, ival) in zip(train_indices, val_indices))
+end
+
+kfolds(data; k) = kfolds(data, k)
+
+"""
+    leaveout(n::Integer, [size = 1]) -> Tuple
+
+Compute the train/validation assignments for `k ≈ n/size`
+repartitions of `n` observations, and return them in the form of
+two vectors. The first vector contains the index-vectors for the
+training subsets, and the second vector the index-vectors for the
+validation subsets respectively. Each validation subset will have
+either `size` or `size+1` observations assigned to it. The
+following code snippet generates the index-vectors for `size = 2`.
+
+```julia
+julia> train_idx, val_idx = leaveout(10, 2);
+```
+
+Each observation is assigned to the validation subset once (and
+only once). Thus, a union over all validation index-vectors
+reproduces the full range `1:n`. Note that there is no random
+assignment of observations to subsets, which means that adjacent
+observations are likely to be part of the same validation subset.
+
+```julia
+julia> train_idx
+5-element Array{Array{Int64,1},1}:
+ [3,4,5,6,7,8,9,10]
+ [1,2,5,6,7,8,9,10]
+ [1,2,3,4,7,8,9,10]
+ [1,2,3,4,5,6,9,10]
+ [1,2,3,4,5,6,7,8]
+
+julia> val_idx
+5-element Array{UnitRange{Int64},1}:
+ 1:2
+ 3:4
+ 5:6
+ 7:8
+ 9:10
+```
+"""
+function leavepout(n::Integer, p::Integer = 1)
+    1 <= p <= floor(n/2) || throw(ArgumentError("p must to be within 1:$(floor(Int,n/2))"))
+    k = floor(Int, n / p)
+    kfolds(n, k)
+end
+
+"""
+    leavepout(data, p = 1)
+
+Repartition a `data` container using a k-fold strategy, where `k`
+is chosen in such a way, that each validation subset of the
+resulting folds contains roughly `p` observations. Defaults to
+`p = 1`, which is also known as "leave-one-out" partitioning.
+
+The resulting sequence of folds is returned as a lazy
+iterator. Only data subsets are created. That means no actual
+data is copied until [`getobs`](@ref) is invoked.
+
+```julia
+for (train, val) in leavepout(X, p=2)
+    # if nobs(X) is dividable by 2,
+    # then numobs(val) will be 2 for each iteraton,
+    # otherwise it may be 3 for the first few iterations.
+end
+```
+
+See[`kfolds`](@ref) for a related function.
+"""
+function leavepout(data, p::Integer)
+    n = numobs(data)
+    1 <= p <= floor(n/2) || throw(ArgumentError("p must to be within 1:$(floor(Int,n/2))"))
+    k = floor(Int, n / p)
+    kfolds(data, k)
+end
+
+leavepout(data; p::Integer=1) = leavepout(data, p)
\ No newline at end of file
diff --git a/src/observation.jl b/src/observation.jl
index 890f3d9..0317a52 100644
--- a/src/observation.jl
+++ b/src/observation.jl
@@ -5,10 +5,10 @@ Return the total number of observations contained in `data`.
 
 See also [`getobs`](@ref)
 """
-function numobs end
+function nobs end
 
 """
-    getobs(data, [idx])
+    getobs(data, idx)
 
 Return the observations corresponding to the observation-index `idx`.
 Note that `idx` can be any type as long as `data` has defined
@@ -46,27 +46,6 @@ function getobs! end
 # getobs!(buffer, data) = getobs(data)
 getobs!(buffer, data, idx) = getobs(data, idx)
 
-# --------------------------------------------------------------------
-# AbstractDataContainer
-# Having an AbstractDataContainer allows to define sensible defaults
-# for Base (or other) interfaces based on our interface.
-# This makes it easier for developers by reducing boilerplate.
-
-abstract type AbstractDataContainer end
-
-Base.getindex(x::AbstractDataContainer, i) = getobs(x, i)
-Base.iterate(x::AbstractDataContainer, state = 1) =
-    (state > numobs(x)) ? nothing : (getobs(x, state), state + 1)
-Base.length(x::AbstractDataContainer) = numobs(x)
-Base.lastindex(x::AbstractDataContainer) = numobs(x)
-
-# --------------------------------------------------------------------
-# AbstractDataIterator
-# Might need this distinction later
-# e.g. shuffleobs can be anywhere in pipeline but
-#      eachbatch is usually at the end
-abstract type AbstractDataIterator <: AbstractDataContainer end
-
 # --------------------------------------------------------------------
 # Arrays
 # We are very opinionated with arrays: the observation dimension
@@ -101,7 +80,7 @@ _check_numobs_error() =
 function _check_numobs(data::Union{Tuple, NamedTuple, Dict})
     length(data) == 0 && return 0
     n = numobs(data[first(keys(data))])
-
+    
     for i in keys(data)
         ni = numobs(data[i])
         n == ni || _check_numobs_error()
@@ -112,7 +91,9 @@ end
 numobs(data::Union{Tuple, NamedTuple}) = _check_numobs(data)
 
 
-getobs(tup::Union{Tuple, NamedTuple}) = map(x -> getobs(x), tup)
+function getobs(tup::Union{Tuple, NamedTuple})
+    return map(x -> getobs(x), tup)
+end
 
 function getobs(tup::Union{Tuple, NamedTuple}, indices)
     _check_numobs(tup)
@@ -120,27 +101,29 @@ function getobs(tup::Union{Tuple, NamedTuple}, indices)
 end
 
 function getobs!(buffers::Union{Tuple, NamedTuple},
-                 tup::Union{Tuple, NamedTuple},
-                 indices)
+                  tup::Union{Tuple, NamedTuple},
+                  indices)
     _check_numobs(tup)
 
     return map(buffers, tup) do buffer, x
-        getobs!(buffer, x, indices)
-    end
+                getobs!(buffer, x, indices)
+            end
 end
 
 ## Dict
 
 numobs(data::Dict) = _check_numobs(data)
 
-getobs(data::Dict, i) = Dict(k => getobs(v, i) for (k, v) in pairs(data))
+function getobs(data::Dict, i)
+    Dict(k => getobs(v, i) for (k, v) in pairs(data))
+end
 
-getobs(data::Dict) = Dict(k => getobs(v) for (k, v) in pairs(data))
+function getobs(data::Dict)
+    Dict(k => getobs(v) for (k, v) in pairs(data))
+end
 
 function getobs!(buffers, data::Dict, i)
     for (k, v) in pairs(data)
         getobs!(buffers[k], v, i)
     end
-
-    return buffers
-end
+ end


Test patch:
diff --git a/test/dataiterator.jl b/test/dataiterator.jl
new file mode 100644
index 0000000..f237ea7
--- /dev/null
+++ b/test/dataiterator.jl
@@ -0,0 +1,40 @@
+@testset "eachobs" begin
+    for (i,x) in enumerate(eachobs(X))
+        @test x == X[:,i]
+    end
+
+    for (i,x) in enumerate(eachobs(X, buffer=true))
+        @test x == X[:,i]
+    end
+
+    b = zeros(size(x, 1))
+    for (i,x) in enumerate(eachobs(X, buffer=b))
+        @test x == X[:,i]
+    end
+    @test b == X[:,end]
+end
+
+@testset "eachbatch" begin
+    @test collect(eachbatch(X, 2)) == collect(eachbatch(X, size=2))
+
+    for (i, x) in enumerate(eachbatch(X, 2, partial=true))
+        if i != 8
+            @test size(x) == (4,2)
+            @test x == X[:,2i-1:2i]
+        else
+            @test size(x) == (4,1)
+            @test x == X[:,2i-1:2i-1]
+        end
+    end
+
+    for (i, x) in enumerate(eachbatch(X, 2, buffer=true, partial=false))
+        @test size(x) == (4,2)
+        @test x == X[:,2i-1:2i]
+    end
+
+    b = zeros(4, 2)
+    for (i, x) in enumerate(eachbatch(X, 2, buffer=b, partial=false))
+        @test size(x) == (4,2)
+        @test x == X[:,2i-1:2i]
+    end
+end
\ No newline at end of file
diff --git a/test/folds.jl b/test/folds.jl
new file mode 100644
index 0000000..8464670
--- /dev/null
+++ b/test/folds.jl
@@ -0,0 +1,38 @@
+@testset "kfolds" begin
+    @test collect(kfolds(1:15, k=5)) == collect(kfolds(1:15, 5))
+    @test kfolds(15, k=5) == kfolds(15, 5)
+
+    @testset "int" begin
+        itrain, ival = kfolds(15, k=5)
+        @test size(itrain) == (5,)
+        @test size(ival) == (5,)
+        @test all(i -> size(i) == (12,), itrain)
+        @test all(i -> size(i) == (3,), ival)
+        tot = [[it;iv] for (it, iv) in zip(itrain, ival)]
+        @test all(x -> sort(x) == 1:15, tot)
+    end
+
+    @testset "data" begin
+        for (xtr, xval) in kfolds(1:15, k=5)
+            @test size(xtr) == (12,)
+            @test size(xval) == (3,)
+            @test sort([xtr; xval]) == 1:15
+        end
+
+        for ((xtr,ytr), (xval, yval)) in kfolds((1:15, 11:25), k=5)
+            @test size(xtr) == (12,)
+            @test size(xval) == (3,)
+            @test size(ytr) == (12,)
+            @test size(yval) == (3,)
+            @test sort([xtr; xval]) == 1:15
+            @test sort([ytr; yval]) == 11:25
+        end
+    end
+end
+
+@testset "leavepout" begin
+    @test leavepout(15) == kfolds(15, k=15)
+    @test collect(leavepout(1:15)) == collect(kfolds(1:15, 15))
+    @test collect(leavepout(1:15, p=5)) == collect(kfolds(1:15, 3))
+end
+
diff --git a/test/runtests.jl b/test/runtests.jl
index 3442235..8c3e932 100644
--- a/test/runtests.jl
+++ b/test/runtests.jl
@@ -42,4 +42,6 @@ MLUtils.getobs(::CustomType, i::AbstractVector) = collect(i)
 @testset "splitobs" begin; include("splitobs.jl"); end
 @testset "shuffleobs" begin; include("shuffleobs.jl"); end
 @testset "dataview" begin; include("dataview.jl"); end
+@testset "dataiterator" begin; include("dataiterator.jl"); end
+@testset "folds" begin; include("folds.jl"); end
 # end