Repository: JuliaML/MLUtils.jl
Pull request #1 (2021-12-26 07:57:44)

numobs and  getobs
Porting `nobs`, `getobs` and `getobs!` from https://github.com/JuliaML/LearnBase.jl/pull/53 with the following differences
- `nobs` is renamed to `numobs`, to avoid conflicts with `StatsBase.nobs`
- We get rid of the `obsdim` keyword, which causes a lot of additional complexity in the implementation here and in downstream packages. Wrapper types can be used in place of it. 

darsnack: What would the wrapper type alternatives to the keyword look like?
CarloLucibello: something like this for `obsdim=1`
```
struct MyDataset
  A::AbstractArray
end
getobs(d::MyDataset, i) = d[i, :]
```
We can also provide a `ArrayDataset{D}`  where `D` parameterizes the observation dimension. I think it is better to have the type set the observation dim than having to deal with the `obsdim` keyword during the whole downstream pipeline  

CarloLucibello: Merging as I would like to go on with the port


Patch:
diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
index a79415c..94d9b6c 100644
--- a/.github/workflows/CI.yml
+++ b/.github/workflows/CI.yml
@@ -19,7 +19,7 @@ jobs:
       matrix:
         version:
           - '1.6'
-          - '1.7'
+          - '1'
           - 'nightly'
         os:
           - ubuntu-latest
diff --git a/Project.toml b/Project.toml
index 32cdac3..48068e7 100644
--- a/Project.toml
+++ b/Project.toml
@@ -1,13 +1,16 @@
-name = "MLBase"
+name = "MLUtils"
 uuid = "f1d291b0-491e-4a28-83b9-f70985020b54"
 authors = ["Carlo Lucibello <carlo.lucibello@gmail.com> and contributors"]
 version = "0.1.0"
 
-[extras]
-Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+[deps]
 
 [compat]
 julia = "1.6"
 
+[extras]
+SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+
 [targets]
-test = ["Test"]
+test = ["SparseArrays", "Test"]
diff --git a/README.md b/README.md
index e0accf1..e565348 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,56 @@
-# MLBase
+# MLUtils
+
+[![Build Status](https://github.com/JuliaML/MLUtils.jl/actions/workflows/CI.yml/badge.svg?branch=main)](https://github.com/JuliaML/MLUtils.jl/actions/workflows/CI.yml?query=branch%3Amain)
+[![Coverage](https://codecov.io/gh/JuliaML/MLUtils.jl/branch/main/graph/badge.svg)](https://codecov.io/gh/JuliaML/MLUtils.jl)
+
+
+This package embodies a community effort to provide common and extensible functionalities for Machine Learning packages in Julia.
+
+The aim is to consolidate packages in the ML ecosystem such as [MLDataPattern.jl](https://github.com/JuliaML/MLDataPattern.jl) and [MLLabelUtils.jl](https://github.com/JuliaML/MLLabelUtils.jl) into a single well-mantained repository.
+
+## Interface
+
+### Observation
+
+The functions `numobs`, `getobs`, and `getobs!` are implemented for basic types (arrays, tuples, ...)
+and can be extend
+
+For array types, the observation dimension is the last dimension. This means that when working 
+with matrices each columns is considered as an individual observation. 
+
+```    
+    numobs(data)
+
+Return the total number of observations contained in `data`.
+See also [`getobs`](@ref)
+```
+
+```
+    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
+`getobs` for that type.
+The returned observation(s) should be in the form intended to
+be passed as-is to some learning algorithm. There is no strict
+interface requirement on how this "actual data" must look like.
+Every author behind some custom data container can make this
+decision themselves.
+The output should be consistent when `idx` is a scalar vs vector.
+See also [`getobs!`](@ref) and [`numobs`](@ref) 
+```
+
+```
+    getobs!(buffer, data, idx)
+
+Inplace version of `getobs(data, idx)`. If this method
+is defined for the type of `data`, then `buffer` should be used
+to store the result, instead of allocating a dedicated object.
+Implementing this function is optional. In the case no such
+method is provided for the type of `data`, then `buffer` will be
+*ignored* and the result of `getobs` returned. This could be
+because the type of `data` may not lend itself to the concept
+of `copy!`. Thus, supporting a custom `getobs!` is optional
+and not required.
+```
 
-[![Build Status](https://github.com/Carlo/MLBase.jl/actions/workflows/CI.yml/badge.svg?branch=main)](https://github.com/Carlo/MLBase.jl/actions/workflows/CI.yml?query=branch%3Amain)
-[![Coverage](https://codecov.io/gh/Carlo/MLBase.jl/branch/main/graph/badge.svg)](https://codecov.io/gh/Carlo/MLBase.jl)
diff --git a/src/MLBase.jl b/src/MLBase.jl
deleted file mode 100644
index 52b12a6..0000000
--- a/src/MLBase.jl
+++ /dev/null
@@ -1,5 +0,0 @@
-module MLBase
-
-# Write your package code here.
-
-end
diff --git a/src/MLUtils.jl b/src/MLUtils.jl
new file mode 100644
index 0000000..c4f5716
--- /dev/null
+++ b/src/MLUtils.jl
@@ -0,0 +1,10 @@
+module MLUtils
+
+
+include("observation.jl")
+export numobs, getobs, getobs!
+
+include("randobs.jl")
+export randobs
+
+end
diff --git a/src/observation.jl b/src/observation.jl
new file mode 100644
index 0000000..c9ed766
--- /dev/null
+++ b/src/observation.jl
@@ -0,0 +1,103 @@
+"""
+    numobs(data)
+
+Return the total number of observations contained in `data`.
+
+See also [`getobs`](@ref)
+"""
+function nobs end
+
+"""
+    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
+`getobs` for that type.
+The returned observation(s) should be in the form intended to
+be passed as-is to some learning algorithm. There is no strict
+interface requirement on how this "actual data" must look like.
+Every author behind some custom data container can make this
+decision themselves.
+The output should be consistent when `idx` is a scalar vs vector.
+
+
+See also [`getobs!`](@ref) and [`numobs`](@ref) 
+"""
+function getobs end
+
+getobs(data, idx) = data[idx]
+
+"""
+    getobs!(buffer, data, idx)
+
+Inplace version of `getobs(data, idx)`. If this method
+is defined for the type of `data`, then `buffer` should be used
+to store the result, instead of allocating a dedicated object.
+Implementing this function is optional. In the case no such
+method is provided for the type of `data`, then `buffer` will be
+*ignored* and the result of `getobs` returned. This could be
+because the type of `data` may not lend itself to the concept
+of `copy!`. Thus, supporting a custom `getobs!` is optional
+and not required.
+"""
+function getobs! end
+getobs!(buffer, data, idx) = getobs(data, idx)
+
+# --------------------------------------------------------------------
+# Arrays
+# We are very opinionated with arrays: the observation dimension
+# is th last dimension. For different behavior wrap the array in 
+# a custom type, e.g. with Tables.table.
+
+
+numobs(A::AbstractArray{<:Any, N}) where {N} = size(A, N)
+
+# 0-dim arrays
+numobs(A::AbstractArray{<:Any, 0}) = 1
+
+function getobs(A::AbstractArray{<:Any, N}, idx) where N
+    I = ntuple(_ -> :, N-1)
+    return A[I..., idx]
+end
+
+getobs(A::AbstractArray{<:Any, 0}, idx) = A[idx]
+
+function getobs!(buffer::AbstractArray, A::AbstractArray{<:Any, N}, idx) where N
+    I = ntuple(_ -> :, N-1)
+    buffer .= A[I..., idx]
+    return buffer
+end
+
+# --------------------------------------------------------------------
+# Tuples and NamedTuples
+
+_check_numobs_error() =
+    throw(DimensionMismatch("All data containers must have the same number of observations."))
+
+function _check_numobs(tup::Union{Tuple, NamedTuple})
+    length(tup) == 0 && return
+    n1 = numobs(tup[1])
+    for i=2:length(tup)
+        numobs(tup[i]) != n1 && _check_numobs_error()
+    end
+end
+
+function numobs(tup::Union{Tuple, NamedTuple})::Int
+    _check_numobs(tup)
+    return length(tup) == 0 ? 0 : numobs(tup[1])
+end
+
+function getobs(tup::Union{Tuple, NamedTuple}, indices)
+    _check_numobs(tup)
+    return map(x -> getobs(x, indices), tup)
+end
+
+function getobs!(buffers::Union{Tuple, NamedTuple},
+                  tup::Union{Tuple, NamedTuple},
+                  indices)
+    _check_numobs(tup)
+
+    return map(buffers, tup) do buffer, x
+                getobs!(buffer, x, indices)
+            end
+end
diff --git a/src/randobs.jl b/src/randobs.jl
new file mode 100644
index 0000000..10000e9
--- /dev/null
+++ b/src/randobs.jl
@@ -0,0 +1,13 @@
+# TODO: allow passing a rng as first parameter
+
+"""
+    randobs(data, [n])
+
+Pick a random observation or a batch of `n` random observations
+from `data`.
+For this function to work, the type of `data` must implement
+[`numobs`](@ref) and [`getobs`](@ref).
+"""
+randobs(data) = getobs(data, rand(1:numobs(data)))
+
+randobs(data, n) = getobs(data, rand(1:numobs(data), n))
\ No newline at end of file


Test patch:
diff --git a/test/observation.jl b/test/observation.jl
new file mode 100644
index 0000000..0823835
--- /dev/null
+++ b/test/observation.jl
@@ -0,0 +1,203 @@
+X = rand(4, 150)
+y = repeat(["setosa","versicolor","virginica"], inner = 50)
+Y = permutedims(hcat(y,y), [2,1])
+Xs = sprand(10,150,.5)
+ys = sprand(150,.5)
+Yt = hcat(y,y)
+yt = Y[1:1,:]
+Xv = view(X,:,:)
+yv = view(y,:)
+XX = rand(20,30,150)
+XXX = rand(3,20,30,150)
+X1 = hcat((1:150 for i = 1:10)...)'
+Y1 = collect(1:150)
+vars = (X, Xv, yv, XX, XXX, y)
+
+@testset "array" begin
+    a = rand(2,3)
+    @test numobs(a) == 3
+    @test @inferred getobs(a, 1) == a[:,1]
+    @test @inferred getobs(a, 2) == a[:,2]
+    @test @inferred getobs(a, 1:2) == a[:,1:2]
+end
+
+@testset "0-dim SubArray" begin
+    v = view([3], 1)
+    @test @inferred(numobs(v)) === 1
+    @test @inferred(getobs(v, 1)) === 3
+    @test_throws BoundsError getobs(v, 2)
+    @test_throws BoundsError getobs(v, 2:3)
+end
+
+
+@testset "named tuple" begin
+    X, Y = rand(2, 3), rand(3)
+    dataset = (x=X, y=Y)
+    @test numobs(dataset) == 3
+    o = @inferred getobs(dataset, 2)
+    @test o.x == X[:,2]
+    @test o.y == Y[2]
+
+    o = @inferred getobs(dataset, 1:2)
+    @test o.x == X[:,1:2]
+    @test o.y == Y[1:2]
+end
+
+# @testset "dict" begin
+#     X, Y = rand(2, 3), rand(3)
+#     dataset = Dict("X" => X, "Y" => Y) 
+#     @test numobs(dataset) == 3
+
+#     # o = @inferred getobs(dataset, 2) # not inferred
+#     o = getobs(dataset, 2)
+#     @test o["X"] == X[:,2]
+#     @test o["Y"] == Y[2]
+
+#     o = getobs(dataset, 1:2)
+#     @test o["X"] == X[:,1:2]
+#     @test o["Y"] == Y[1:2]
+# end
+
+
+@testset "numobs" begin
+    @test_throws MethodError numobs(X,X)
+    @test_throws MethodError numobs(X,y)
+
+    @testset "Array, SparseArray, and Tuple" begin
+        @test_throws DimensionMismatch numobs((X,XX,rand(100)))
+        @test_throws DimensionMismatch numobs((X,X'))
+        for var in (Xs, ys, vars...)
+            @test @inferred(numobs(var)) === 150
+        end
+        @test @inferred(numobs(())) === 0
+    end
+
+    @testset "SubArray" begin
+        @test @inferred(numobs(view(X,:,:))) === 150
+        @test @inferred(numobs(view(X,:,:))) === 150
+        @test @inferred(numobs(view(XX,:,:,:))) === 150
+        @test @inferred(numobs(view(XXX,:,:,:,:))) === 150
+        @test @inferred(numobs(view(y,:))) === 150
+        @test @inferred(numobs(view(Y,:,:))) === 150
+    end
+end
+
+@testset "getobs" begin
+    @testset "Array and Subarray" begin
+        # access outside numobs bounds
+        @test_throws BoundsError getobs(X, -1)
+        @test_throws BoundsError getobs(X, 0)
+        @test_throws BoundsError getobs(X, 151)
+        for i in (2, 2:20, [2,1,4])
+            @test getobs(XX, i) == XX[:, :, i]
+        end
+        for i in (2, 1:150, 2:10, [2,5,7], [2,1])
+            @test typeof(getobs(Xv, i)) <: Array
+            @test typeof(getobs(yv, i)) <: ((i isa Int) ? String : Array)
+            @test all(getobs(Xv, i) .== X[:, i])
+            @test getobs(Xv,i)  == X[:,i]
+            @test getobs(X,i)   == X[:,i]
+            @test getobs(XX,i)  == XX[:,:,i]
+            @test getobs(XXX,i) == XXX[:,:,:,i]
+            @test getobs(y,i)   == y[i]
+            @test getobs(yv,i)  == y[i]
+            @test getobs(Y,i)   == Y[:,i]
+        end
+    end
+
+    @testset "SparseArray" begin
+        @test typeof(getobs(Xs,2)) <: SparseVector
+        @test typeof(getobs(Xs,1:5)) <: SparseMatrixCSC
+        @test typeof(getobs(ys,2)) <: Float64
+        @test typeof(getobs(ys,1:5)) <: SparseVector
+        for i in (2, 1:150, 2:10, [2,5,7], [2,1])
+            @test getobs(Xs,i) == Xs[:,i]
+            @test getobs(ys,i) == ys[i]
+        end
+    end
+
+    @testset "Tuple" begin
+        # bounds checking correctly
+        @test_throws BoundsError getobs((X,y), 151)
+        # special case empty tuple
+        @test @inferred(getobs((), 10)) === ()
+        tx, ty = getobs((Xv, yv), 10:50)
+        @test typeof(tx) <: Array
+        @test typeof(ty) <: Array
+        for i in (1:150, 2:10, [2,5,7], [2,1])
+            @test_throws DimensionMismatch getobs((X', y), i)
+            @test @inferred(getobs((X,y), i))  == (X[:,i], y[i])
+            @test @inferred(getobs((X,yv), i)) == (X[:,i], y[i])
+            @test @inferred(getobs((Xv,y), i)) == (X[:,i], y[i])
+            @test @inferred(getobs((X,Y), i))  == (X[:,i], Y[:,i])
+            @test @inferred(getobs((X,yt), i)) == (X[:,i], yt[:,i])
+            @test @inferred(getobs((XX,X,y), i)) == (XX[:,:,i], X[:,i], y[i])
+            # compare if obs match in tuple
+            x1, y1 = getobs((X1,Y1), i)
+            @test all(x1' .== y1)
+            x1, y1, z1 = getobs((X1,Y1,sparse(X1)), i)
+            @test all(x1' .== y1)
+            @test all(x1 .== z1)
+        end
+        @test getobs((Xv,y), 2) == (X[:,2], y[2])
+        @test getobs((X,yv), 2) == (X[:,2], y[2])
+        @test getobs((X,Y), 2) == (X[:,2], Y[:,2])
+        @test getobs((XX,X,y), 2) == (XX[:,:,2], X[:,2], y[2])
+    end
+end
+
+@testset "getobs!" begin
+    @testset "Array and Subarray" begin
+        Xbuf = similar(X)
+        @test_throws MethodError getobs!(Xbuf, X)
+        # access outside numobs bounds
+        @test_throws BoundsError getobs!(Xbuf, X, -1)
+        @test_throws BoundsError getobs!(Xbuf, X, 0)
+        @test_throws BoundsError getobs!(Xbuf, X, 151)
+        
+        buff = zeros(4)
+        @test @inferred(getobs!(buff, X, 45)) == getobs(X, 45)
+        
+        buff = zeros(4, 8)
+        @test @inferred(getobs!(buff, X, 3:10)) == getobs(X, 3:10)
+        
+        buff = zeros(20,30)
+        @test @inferred(getobs!(buff, XX, 5)) == XX[:,:,5]
+        
+        buff = zeros(20, 30, 5)
+        @test @inferred(getobs!(buff, XX, 6:10)) == XX[:,:,6:10]
+        
+        # string vector
+        @test getobs!("setosa", y, 1) == "setosa"
+        @test getobs!(nothing, y, 1) == "setosa"
+    end
+
+    @testset "SparseArray" begin
+        # Sparse Arrays opt-out of buffer usage
+        @test @inferred(getobs!(nothing, Xs, 1)) == getobs(Xs, 1)
+        @test @inferred(getobs!(nothing, Xs, 5:10)) == getobs(Xs, 5:10)
+        @test @inferred(getobs!(nothing, ys, 1)) === getobs(ys, 1)
+        @test @inferred(getobs!(nothing, ys, 5:10)) == getobs(ys, 5:10)
+    end
+
+    @testset "Tuple" begin
+        @test_throws MethodError getobs!((nothing,nothing), (X,y))
+        @test getobs!((nothing,nothing), (X,y), 1:5) == getobs((X,y), 1:5)
+        @test_throws MethodError getobs!((nothing,nothing,nothing), (X,y))
+        xbuf = zeros(4,2)
+        ybuf = ["foo", "bar"]
+        @test_throws MethodError getobs!((xbuf,), (X,y))
+        @test_throws MethodError getobs!((xbuf,ybuf,ybuf), (X,y))
+        @test_throws DimensionMismatch getobs!((xbuf,), (X,y), 1:5)
+        @test_throws DimensionMismatch getobs!((xbuf,ybuf,ybuf), (X,y), 1:5)
+        @test @inferred(getobs!((xbuf,ybuf),(X,y), 2:3)) === (xbuf,ybuf)
+        @test xbuf == getobs(X, 2:3)
+        @test ybuf == getobs(y, 2:3)
+        @test @inferred(getobs!((xbuf,ybuf),(X,y), [50,150])) === (xbuf,ybuf)
+        @test xbuf == getobs(X, [50,150])
+        @test ybuf == getobs(y, [50,150])
+
+        @test getobs!((nothing,xbuf),(Xs,X), 3:4) == (getobs(Xs,3:4),xbuf)
+        @test xbuf == getobs(X,3:4)
+    end
+end
diff --git a/test/randobs.jl b/test/randobs.jl
new file mode 100644
index 0000000..dc6e916
--- /dev/null
+++ b/test/randobs.jl
@@ -0,0 +1,9 @@
+X = rand(3, 4)
+x = randobs(X)
+@test size(x) == (3,)
+@test any(X[:,i] == x for i in 1:4) 
+
+x = randobs(X, 2) 
+@test size(x) == (3, 2)
+@test any(X[:,i] == x[:,1] for i in 1:4) 
+@test any(X[:,i] == x[:,2] for i in 1:4)
diff --git a/test/runtests.jl b/test/runtests.jl
index 0ff5418..2116e60 100644
--- a/test/runtests.jl
+++ b/test/runtests.jl
@@ -1,6 +1,12 @@
-using MLBase
+using MLUtils
+using SparseArrays
 using Test
 
-@testset "MLBase.jl" begin
-    # Write your tests here.
+@testset "MLUtils.jl" begin
+    @testset "observation" begin
+        include("observation.jl")
+    end
+    @testset "randobs" begin
+        include("randobs.jl")
+    end
 end