From 89d8880145bfec2aa31def4cb9bb9c1926f45e35 Mon Sep 17 00:00:00 2001 From: Ajith Goveas Date: Mon, 24 Aug 2026 22:32:10 +0530 Subject: [PATCH 1/2] feat(data): add Iris dataset provider for one-line classification tutorials (#1044) Add a first-class Iris dataset provider to skainet-data-simple, enabling: val (train, test) = Iris.load().split(0.8, seed = 42L, stratified = true) Three new commonMain files: - IrisData.kt: IrisSample data class with named fields (sepalLength, sepalWidth, petalLength, petalWidth, label), embedded 150-row CSV (UCI canonical ordering, public domain), and parseIrisCsv() with strict error messages naming line number and column. - IrisDataset.kt: Dataset implementation. X is FP32 [batch, 4] raw centimetres; Y is FP32 [batch, 3] one-hot. Both createDataBatch and createIndexedDataBatch are overridden (trap 1 from the issue) so that split(), shuffle() and filter() views batch correctly. getY() returns Int class index, not one-hot (trap 2). - Iris.kt: entry object with featureNames, classNames, and suspend fun load(). The suspend modifier is for call-site symmetry with MNIST.loadTrain() and CIFAR10.load() even though loading is in-memory. Embedded CSV (no network, no cache directory) keeps the provider platform-agnostic including JS and Wasm browser targets where the KMP resources plugin is not wired. IrisDatasetTest lives in jvmTest (same convention as MNIST/FashionMNIST/CIFAR tests) and covers: - 150 samples, 50 per class - feature values in documented ranges (sepal length 4.3-7.9, petal width 0.1-2.5) - class indices match classNames ordering (setosa=0, versicolor=1, virginica=2) - dataBatch produces [n,4] and [n,3] tensors with correct one-hot rows - stratified split(0.8, seed=42L) yields 120/30 with 40/10 per class - split-then-batch regression test for trap 1 (non-contiguous index views) - seed determinism across load calls - parser error messages name line number and column/field README.md and data-sources-getting-started.adoc updated to list Iris in the built-in loaders section. --- README.md | 2 +- .../data-sources-getting-started.adoc | 14 ++ .../kotlin/sk/ainet/data/iris/Iris.kt | 44 ++++ .../kotlin/sk/ainet/data/iris/IrisData.kt | 226 ++++++++++++++++++ .../kotlin/sk/ainet/data/iris/IrisDataset.kt | 106 ++++++++ .../sk/ainet/data/iris/IrisDatasetTest.kt | 153 ++++++++++++ 6 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 skainet-data/skainet-data-simple/src/commonMain/kotlin/sk/ainet/data/iris/Iris.kt create mode 100644 skainet-data/skainet-data-simple/src/commonMain/kotlin/sk/ainet/data/iris/IrisData.kt create mode 100644 skainet-data/skainet-data-simple/src/commonMain/kotlin/sk/ainet/data/iris/IrisDataset.kt create mode 100644 skainet-data/skainet-data-simple/src/jvmTest/kotlin/sk/ainet/data/iris/IrisDatasetTest.kt diff --git a/README.md b/README.md index c7dff6e1d..ae51c0ea7 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,7 @@ Runnable examples: ### Data and I/O -- Built-in loaders: MNIST, Fashion-MNIST, CIFAR-10 +- Built-in loaders: MNIST, Fashion-MNIST, CIFAR-10, Iris - URI-backed data sources: `file://`, `https://`, `hf+https://`, and `hf://...` - Dataset operations: deterministic shuffle/split, stratified split, filter/map/transform views, batch flows, and epoch flows - Raw dataset parsers: CSV, TSV, JSON arrays/objects, JSON Lines (`.jsonl`, `.ndjson`) diff --git a/docs/modules/ROOT/pages/tutorials/data-sources-getting-started.adoc b/docs/modules/ROOT/pages/tutorials/data-sources-getting-started.adoc index 5b693d144..99c4ddd3d 100644 --- a/docs/modules/ROOT/pages/tutorials/data-sources-getting-started.adoc +++ b/docs/modules/ROOT/pages/tutorials/data-sources-getting-started.adoc @@ -120,6 +120,20 @@ val train = MNIST.loadTrain( val batches = train.batchIterator(batchSize = 64) ---- +The Iris provider is different: the 150-row dataset ships embedded inside the +library itself, so loading it needs no network access, no cache directory and +works identically on every platform target. + +[source,kotlin] +---- +import kotlinx.coroutines.runBlocking +import sk.ainet.data.iris.Iris + +val (train, test) = runBlocking { + Iris.load().split(0.8, seed = 42L, stratified = true) +} +---- + === Cache behavior Use `CachePolicy.Use` for normal operation, `Refresh` to re-download, diff --git a/skainet-data/skainet-data-simple/src/commonMain/kotlin/sk/ainet/data/iris/Iris.kt b/skainet-data/skainet-data-simple/src/commonMain/kotlin/sk/ainet/data/iris/Iris.kt new file mode 100644 index 000000000..cdf97a81d --- /dev/null +++ b/skainet-data/skainet-data-simple/src/commonMain/kotlin/sk/ainet/data/iris/Iris.kt @@ -0,0 +1,44 @@ +package sk.ainet.data.iris + +/** + * Entry point for the Iris dataset, mirroring the other SKaiNET data + * providers (`MNIST`, `FashionMNIST`, `CIFAR10`). + * + * The dataset ships embedded inside the library, so [load] needs no network + * access, no cache directory and works identically on every platform target. + * + * Example: + * ```kotlin + * val (train, test) = Iris.load().split(0.8, seed = 42L, stratified = true) + * ``` + */ +public object Iris { + + /** + * Feature column order used by every tensor this provider produces. + * + * The order is part of the public contract: never rely on map iteration + * order or CSV field position — index into feature arrays with these names. + */ + public val featureNames: List = + listOf("sepalLength", "sepalWidth", "petalLength", "petalWidth") + + /** + * Species names indexed by class label. The mapping is fixed and + * alphabetical: 0 = "Iris-setosa", 1 = "Iris-versicolor", + * 2 = "Iris-virginica". Stratified splits and one-hot batches both + * depend on this ordering staying stable. + */ + public val classNames: List = + listOf("Iris-setosa", "Iris-versicolor", "Iris-virginica") + + /** + * Loads the bundled copy of the Iris dataset. + * + * The function is `suspend` for call-site symmetry with the downloading + * providers (`MNIST.loadTrain()` & co.) even though loading is purely + * in-memory parsing. + */ + @Suppress("RedundantSuspendModifier") + public suspend fun load(): IrisDataset = IrisDataset(parseIrisCsv(IRIS_CSV)) +} diff --git a/skainet-data/skainet-data-simple/src/commonMain/kotlin/sk/ainet/data/iris/IrisData.kt b/skainet-data/skainet-data-simple/src/commonMain/kotlin/sk/ainet/data/iris/IrisData.kt new file mode 100644 index 000000000..bd2820b80 --- /dev/null +++ b/skainet-data/skainet-data-simple/src/commonMain/kotlin/sk/ainet/data/iris/IrisData.kt @@ -0,0 +1,226 @@ +package sk.ainet.data.iris + +/** + * One Iris flower: four physical measurements in centimetres plus its species index. + * + * @property sepalLength Sepal length in cm. + * @property sepalWidth Sepal width in cm. + * @property petalLength Petal length in cm. + * @property petalWidth Petal width in cm. + * @property label Species as a class index into [Iris.classNames] + * (0 = Iris-setosa, 1 = Iris-versicolor, 2 = Iris-virginica). + */ +public data class IrisSample( + val sepalLength: Float, + val sepalWidth: Float, + val petalLength: Float, + val petalWidth: Float, + val label: Int +) { + /** Returns the four measurements in the fixed feature order given by [Iris.featureNames]. */ + public fun toFeatures(): FloatArray = + floatArrayOf(sepalLength, sepalWidth, petalLength, petalWidth) +} + +/** + * The complete Iris dataset (Fisher, 1936) embedded verbatim: 150 rows of + * `sepalLength,sepalWidth,petalLength,petalWidth,species` in the canonical + * UCI ordering (50 rows per species). The data is in the public domain. + * + * It is embedded as source rather than as a resource file so that every + * platform target of this module — including JS and Wasm browsers — can load + * it with no I/O and no network access. + */ +internal val IRIS_CSV: String = """ + 5.1,3.5,1.4,0.2,Iris-setosa + 4.9,3.0,1.4,0.2,Iris-setosa + 4.7,3.2,1.3,0.2,Iris-setosa + 4.6,3.1,1.5,0.2,Iris-setosa + 5.0,3.6,1.4,0.2,Iris-setosa + 5.4,3.9,1.7,0.4,Iris-setosa + 4.6,3.4,1.4,0.3,Iris-setosa + 5.0,3.4,1.5,0.2,Iris-setosa + 4.4,2.9,1.4,0.2,Iris-setosa + 4.9,3.1,1.5,0.1,Iris-setosa + 5.4,3.7,1.5,0.2,Iris-setosa + 4.8,3.4,1.6,0.2,Iris-setosa + 4.8,3.0,1.4,0.1,Iris-setosa + 4.3,3.0,1.1,0.1,Iris-setosa + 5.8,4.0,1.2,0.2,Iris-setosa + 5.7,4.4,1.5,0.4,Iris-setosa + 5.4,3.9,1.3,0.4,Iris-setosa + 5.1,3.5,1.4,0.3,Iris-setosa + 5.7,3.8,1.7,0.3,Iris-setosa + 5.1,3.8,1.5,0.3,Iris-setosa + 5.4,3.4,1.7,0.2,Iris-setosa + 5.1,3.7,1.5,0.4,Iris-setosa + 4.6,3.6,1.0,0.2,Iris-setosa + 5.1,3.3,1.7,0.5,Iris-setosa + 4.8,3.4,1.9,0.2,Iris-setosa + 5.0,3.0,1.6,0.2,Iris-setosa + 5.0,3.4,1.6,0.4,Iris-setosa + 5.2,3.5,1.5,0.2,Iris-setosa + 5.2,3.4,1.4,0.2,Iris-setosa + 4.7,3.2,1.6,0.2,Iris-setosa + 4.8,3.1,1.6,0.2,Iris-setosa + 5.4,3.4,1.5,0.4,Iris-setosa + 5.2,4.1,1.5,0.1,Iris-setosa + 5.5,4.2,1.4,0.2,Iris-setosa + 4.9,3.1,1.5,0.1,Iris-setosa + 5.0,3.2,1.2,0.2,Iris-setosa + 5.5,3.5,1.3,0.2,Iris-setosa + 4.9,3.1,1.5,0.1,Iris-setosa + 4.4,3.0,1.3,0.2,Iris-setosa + 5.1,3.4,1.5,0.2,Iris-setosa + 5.0,3.5,1.3,0.3,Iris-setosa + 4.5,2.3,1.3,0.3,Iris-setosa + 4.4,3.2,1.3,0.2,Iris-setosa + 5.0,3.5,1.6,0.6,Iris-setosa + 5.1,3.8,1.9,0.4,Iris-setosa + 4.8,3.0,1.4,0.3,Iris-setosa + 5.1,3.8,1.6,0.2,Iris-setosa + 4.6,3.2,1.4,0.2,Iris-setosa + 5.3,3.7,1.5,0.2,Iris-setosa + 5.0,3.3,1.4,0.2,Iris-setosa + 7.0,3.2,4.7,1.4,Iris-versicolor + 6.4,3.2,4.5,1.5,Iris-versicolor + 6.9,3.1,4.9,1.5,Iris-versicolor + 5.5,2.3,4.0,1.3,Iris-versicolor + 6.5,2.8,4.6,1.5,Iris-versicolor + 5.7,2.8,4.5,1.3,Iris-versicolor + 6.3,3.3,4.7,1.6,Iris-versicolor + 4.9,2.4,3.3,1.0,Iris-versicolor + 6.6,2.9,4.6,1.3,Iris-versicolor + 5.2,2.7,3.9,1.4,Iris-versicolor + 5.0,2.0,3.5,1.0,Iris-versicolor + 5.9,3.0,4.2,1.5,Iris-versicolor + 6.0,2.2,4.0,1.0,Iris-versicolor + 6.1,2.9,4.7,1.4,Iris-versicolor + 5.6,2.9,3.6,1.3,Iris-versicolor + 6.7,3.1,4.4,1.4,Iris-versicolor + 5.6,3.0,4.5,1.5,Iris-versicolor + 5.8,2.7,4.1,1.0,Iris-versicolor + 6.2,2.2,4.5,1.5,Iris-versicolor + 5.6,2.5,3.9,1.1,Iris-versicolor + 5.9,3.2,4.8,1.8,Iris-versicolor + 6.1,2.8,4.0,1.3,Iris-versicolor + 6.3,2.5,4.9,1.5,Iris-versicolor + 6.1,2.8,4.7,1.2,Iris-versicolor + 6.4,2.9,4.3,1.3,Iris-versicolor + 6.6,3.0,4.4,1.4,Iris-versicolor + 6.8,2.8,4.8,1.4,Iris-versicolor + 6.7,3.0,5.0,1.7,Iris-versicolor + 6.0,2.9,4.5,1.5,Iris-versicolor + 5.7,2.6,3.5,1.0,Iris-versicolor + 5.5,2.4,3.8,1.1,Iris-versicolor + 5.5,2.4,3.7,1.0,Iris-versicolor + 5.8,2.7,3.9,1.2,Iris-versicolor + 6.0,2.7,5.1,1.6,Iris-versicolor + 5.4,3.0,4.5,1.5,Iris-versicolor + 6.0,3.4,4.5,1.6,Iris-versicolor + 6.7,3.1,4.7,1.5,Iris-versicolor + 6.3,2.3,4.4,1.3,Iris-versicolor + 5.6,3.0,4.1,1.3,Iris-versicolor + 5.5,2.5,4.0,1.3,Iris-versicolor + 5.5,2.6,4.4,1.2,Iris-versicolor + 6.1,3.0,4.6,1.4,Iris-versicolor + 5.8,2.6,4.0,1.2,Iris-versicolor + 5.0,2.3,3.3,1.0,Iris-versicolor + 5.6,2.7,4.2,1.3,Iris-versicolor + 5.7,3.0,4.2,1.2,Iris-versicolor + 5.7,2.9,4.2,1.3,Iris-versicolor + 6.2,2.9,4.3,1.3,Iris-versicolor + 5.1,2.5,3.0,1.1,Iris-versicolor + 5.7,2.8,4.1,1.3,Iris-versicolor + 6.3,3.3,6.0,2.5,Iris-virginica + 5.8,2.7,5.1,1.9,Iris-virginica + 7.1,3.0,5.9,2.1,Iris-virginica + 6.3,2.9,5.6,1.8,Iris-virginica + 6.5,3.0,5.8,2.2,Iris-virginica + 7.6,3.0,6.6,2.1,Iris-virginica + 4.9,2.5,4.5,1.7,Iris-virginica + 7.3,2.9,6.3,1.8,Iris-virginica + 6.7,2.5,5.8,1.8,Iris-virginica + 7.2,3.6,6.1,2.5,Iris-virginica + 6.5,3.2,5.1,2.0,Iris-virginica + 6.4,2.7,5.3,1.9,Iris-virginica + 6.8,3.0,5.5,2.1,Iris-virginica + 5.7,2.5,5.0,2.0,Iris-virginica + 5.8,2.8,5.1,2.4,Iris-virginica + 6.4,3.2,5.3,2.3,Iris-virginica + 6.5,3.0,5.5,1.8,Iris-virginica + 7.7,3.8,6.7,2.2,Iris-virginica + 7.7,2.6,6.9,2.3,Iris-virginica + 6.0,2.2,5.0,1.5,Iris-virginica + 6.9,3.2,5.7,2.3,Iris-virginica + 5.6,2.8,4.9,2.0,Iris-virginica + 7.7,2.8,6.7,2.0,Iris-virginica + 6.3,2.7,4.9,1.8,Iris-virginica + 6.7,3.3,5.7,2.1,Iris-virginica + 7.2,3.2,6.0,1.8,Iris-virginica + 6.2,2.8,4.8,1.8,Iris-virginica + 6.1,3.0,4.9,1.8,Iris-virginica + 6.4,2.8,5.6,2.1,Iris-virginica + 7.2,3.0,5.8,1.6,Iris-virginica + 7.4,2.8,6.1,1.9,Iris-virginica + 7.9,3.8,6.4,2.0,Iris-virginica + 6.4,2.8,5.6,2.2,Iris-virginica + 6.3,2.8,5.1,1.5,Iris-virginica + 6.1,2.6,5.6,1.4,Iris-virginica + 7.7,3.0,6.1,2.3,Iris-virginica + 6.3,3.4,5.6,2.4,Iris-virginica + 6.4,3.1,5.5,1.8,Iris-virginica + 6.0,3.0,4.8,1.8,Iris-virginica + 6.9,3.1,5.4,2.1,Iris-virginica + 6.7,3.1,5.6,2.4,Iris-virginica + 6.9,3.1,5.1,2.3,Iris-virginica + 5.8,2.7,5.1,1.9,Iris-virginica + 6.8,3.2,5.9,2.3,Iris-virginica + 6.7,3.3,5.7,2.5,Iris-virginica + 6.7,3.0,5.2,2.3,Iris-virginica + 6.3,2.5,5.0,1.9,Iris-virginica + 6.5,3.0,5.2,2.0,Iris-virginica + 6.2,3.4,5.4,2.3,Iris-virginica + 5.9,3.0,5.1,1.8,Iris-virginica +""".trimIndent() + +/** + * Parses the embedded Iris CSV into samples. + * + * Errors always name the offending line number and column, e.g. an unknown + * species or a non-numeric measurement fails fast instead of silently + * producing a broken sample. + */ +internal fun parseIrisCsv(csv: String): List { + val featureCount = Iris.featureNames.size + return csv.lines() + .withIndex() + .filter { (_, line) -> line.isNotBlank() } + .map { (index, line) -> + val lineNumber = index + 1 + val fields = line.split(",") + require(fields.size == featureCount + 1) { + "Iris CSV line $lineNumber has ${fields.size} fields but expected ${featureCount + 1}" + } + + fun featureAt(column: Int): Float = + fields[column].trim().toFloatOrNull() + ?: throw IllegalArgumentException( + "Iris CSV line $lineNumber column '${Iris.featureNames[column]}' is not a valid number: '${fields[column].trim()}'" + ) + + val speciesField = fields[featureCount].trim() + val label = Iris.classNames.indexOf(speciesField) + require(label >= 0) { + "Iris CSV line $lineNumber has unknown species '$speciesField'; expected one of $Iris.classNames" + } + + IrisSample( + sepalLength = featureAt(0), + sepalWidth = featureAt(1), + petalLength = featureAt(2), + petalWidth = featureAt(3), + label = label + ) + } +} diff --git a/skainet-data/skainet-data-simple/src/commonMain/kotlin/sk/ainet/data/iris/IrisDataset.kt b/skainet-data/skainet-data-simple/src/commonMain/kotlin/sk/ainet/data/iris/IrisDataset.kt new file mode 100644 index 000000000..a2fb97809 --- /dev/null +++ b/skainet-data/skainet-data-simple/src/commonMain/kotlin/sk/ainet/data/iris/IrisDataset.kt @@ -0,0 +1,106 @@ +package sk.ainet.data.iris + +import sk.ainet.context.DefaultDataExecutionContext +import sk.ainet.context.ExecutionContext +import sk.ainet.data.DataBatch +import sk.ainet.data.Dataset +import sk.ainet.lang.tensor.Shape +import sk.ainet.lang.tensor.Tensor +import sk.ainet.lang.types.DType +import sk.ainet.lang.types.FP32 +import kotlin.math.min +import kotlin.random.Random + +/** + * The Iris dataset (Fisher, 1936) as a [Dataset]: 150 samples, 4 features, + * 3 balanced classes. + * + * Batching produces FP32 tensors: + * x -> Tensor [batch, 4] raw measurements in cm (row-major, + * ordered by [Iris.featureNames]) + * y -> Tensor [batch, 3] one-hot species vectors + * + * [getY] deliberately returns the class index ([Int]) rather than the one-hot + * vector: stratified splitting buckets samples by `Y`, which requires value + * equality. The one-hot conversion happens inside batch construction. + */ +public data class IrisDataset( + val samples: List, + private val executionContext: ExecutionContext = DefaultDataExecutionContext() +) : Dataset() { + + override val inputShape: Shape get() = Shape(Iris.featureNames.size) + + override val outputShape: Shape get() = Shape(Iris.classNames.size) + + override val xSize: Int get() = samples.size + + /** Returns the four measurements of sample [idx], ordered by [Iris.featureNames]. */ + override fun getX(idx: Int): FloatArray = samples[idx].toFeatures() + + /** Returns the species class index of sample [idx]. */ + override fun getY(idx: Int): Int = samples[idx].label + + override fun shuffle(): Dataset = + IrisDataset(samples.shuffled(Random.Default), executionContext) + + override fun split(splitRatio: Double): Pair, Dataset> { + require(splitRatio > 0.0 && splitRatio < 1.0) { "splitRatio must be in (0,1)" } + val at = (samples.size * splitRatio).toInt() + return IrisDataset(samples.subList(0, at).toList(), executionContext) to + IrisDataset(samples.subList(at, samples.size).toList(), executionContext) + } + + /** + * Creates a data batch over the contiguous range starting at [batchStart]. + * + * Delegates to [createBatchFor]; see also [createIndexedDataBatch], which + * serves shuffled, split and filtered dataset views. + */ + override fun createDataBatch(batchStart: Int, batchLength: Int): DataBatch { + val length = min(batchLength, xSize - batchStart) + return createBatchFor(IntArray(length) { offset -> batchStart + offset }) + } + + /** + * Creates a data batch for arbitrary logical sample [indices]. + * + * This override is what keeps `split(...)`, `shuffle(...)` and `filter { }` + * views working with tensor batching: those views hold non-contiguous + * indices and would otherwise hit the base class' contiguous-only default. + */ + override fun createIndexedDataBatch(indices: IntArray): DataBatch = + createBatchFor(indices) + + @Suppress("UNCHECKED_CAST") + private fun createBatchFor(indices: IntArray): DataBatch { + require(indices.isNotEmpty()) { "indices must not be empty" } + val n = indices.size + val featureCount = Iris.featureNames.size + val classCount = Iris.classNames.size + + val xData = FloatArray(n * featureCount) + val yData = FloatArray(n * classCount) + indices.forEachIndexed { row, sampleIndex -> + val sample = samples[sampleIndex] + val features = sample.toFeatures() + features.copyInto(xData, destinationOffset = row * featureCount) + yData[row * classCount + sample.label] = 1.0f + } + + val x: Tensor = + executionContext.fromFloatArray(Shape(n, featureCount), FP32::class, xData) + val y: Tensor = + executionContext.fromFloatArray(Shape(n, classCount), FP32::class, yData) + + return DataBatch( + x = arrayOf(x) as Array>, + y = y as Tensor, + indices = indices.copyOf() + ) + } + + /** Returns a subset of the dataset covering `[fromIndex, toIndex)`. */ + public fun subset(fromIndex: Int, toIndex: Int): IrisDataset = + IrisDataset(samples.subList(fromIndex, toIndex), executionContext) +} diff --git a/skainet-data/skainet-data-simple/src/jvmTest/kotlin/sk/ainet/data/iris/IrisDatasetTest.kt b/skainet-data/skainet-data-simple/src/jvmTest/kotlin/sk/ainet/data/iris/IrisDatasetTest.kt new file mode 100644 index 000000000..b20e1d245 --- /dev/null +++ b/skainet-data/skainet-data-simple/src/jvmTest/kotlin/sk/ainet/data/iris/IrisDatasetTest.kt @@ -0,0 +1,153 @@ +package sk.ainet.data.iris + +import kotlinx.coroutines.runBlocking +import sk.ainet.data.Dataset +import sk.ainet.lang.types.FP32 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class IrisDatasetTest { + + @Test + fun loadReturnsExactly150SamplesWith50PerClass() = runBlocking { + val dataset = Iris.load() + + assertEquals(150, dataset.xSize) + assertEquals( + mapOf(0 to 50, 1 to 50, 2 to 50), + classCounts(dataset) + ) + } + + @Test + fun featuresStayWithinDocumentedRanges() = runBlocking { + val dataset = Iris.load() + + for (idx in 0 until dataset.xSize) { + val x = dataset.getX(idx) + assertEquals(4, x.size) + assertTrue(x[0] in 4.3f..7.9f, "sepalLength out of range at $idx: ${x[0]}") + assertTrue(x[1] in 2.0f..4.4f, "sepalWidth out of range at $idx: ${x[1]}") + assertTrue(x[2] in 1.0f..6.9f, "petalLength out of range at $idx: ${x[2]}") + assertTrue(x[3] in 0.1f..2.5f, "petalWidth out of range at $idx: ${x[3]}") + } + } + + @Test + fun classIndicesAgreeWithClassNamesOrdering() = runBlocking { + val dataset = Iris.load() + + // The embedded CSV is grouped in blocks of 50 per species. + for ((blockStart, expectedLabel) in listOf(0 to 0, 50 to 1, 100 to 2)) { + val sample = dataset.samples[blockStart] + assertEquals(Iris.classNames[expectedLabel], speciesOfCsvLine(blockStart)) + assertEquals(expectedLabel, sample.label) + assertEquals(expectedLabel, dataset.getY(blockStart)) + } + + // Known-good first row of the canonical dataset. + assertEquals(IrisSample(5.1f, 3.5f, 1.4f, 0.2f, 0), dataset.samples.first()) + } + + @Test + fun dataBatchProducesFeatureAndOneHotTensorsOfTheRightShape() = runBlocking { + val dataset = Iris.load() + val batch = dataset.dataBatch(batchStart = 16, batchLength = 8) + + assertEquals(8, batch.batchSize) + assertEquals(listOf(8, 4), batch.x[0].shape.dimensions.toList()) + assertEquals(listOf(8, 3), batch.y.shape.dimensions.toList()) + assertEquals((16 until 24).toList(), batch.indices.toList()) + + val x = batch.x[0].data.copyToFloatArray() + val y = batch.y.data.copyToFloatArray() + for (row in 0 until 8) { + val sourceIdx = 16 + row + val features = dataset.getX(sourceIdx) + for (col in 0 until 4) { + assertEquals(features[col], x[row * 4 + col], "x mismatch at row $row column $col") + } + assertEquals(1.0f, y[row * 3 + dataset.getY(sourceIdx)]) + assertEquals( + 1.0f, + y[row * 3] + y[row * 3 + 1] + y[row * 3 + 2], + "one-hot row $row must sum to 1.0" + ) + } + } + + @Test + fun stratifiedSplitGives120Train30TestWithBalancedClasses() = runBlocking { + val (train, test) = Iris.load().split(splitRatio = 0.8, seed = 42L, stratified = true) + + assertEquals(120, train.xSize) + assertEquals(30, test.xSize) + assertEquals(mapOf(0 to 40, 1 to 40, 2 to 40), classCounts(train)) + assertEquals(mapOf(0 to 10, 1 to 10, 2 to 10), classCounts(test)) + } + + @Test + fun splitThenBatchDoesNotThrow() = runBlocking { + val (train, _) = Iris.load().split(splitRatio = 0.8, seed = 42L, stratified = true) + + // split() returns a non-contiguous index view; batching over that view + // must route through createIndexedDataBatch instead of failing. + val iterator = train.batchIterator(batchSize = 16) + var rows = 0 + while (iterator.hasNext()) { + rows += iterator.next().batchSize + } + assertEquals(train.xSize, rows) + + // Same guarantee for the shuffled-view path. + val shuffledIterator = Iris.load().shuffle(seed = 7L).batchIterator(batchSize = 32) + var shuffledRows = 0 + while (shuffledIterator.hasNext()) { + shuffledRows += shuffledIterator.next().batchSize + } + assertEquals(150, shuffledRows) + } + + @Test + fun sameSeedProducesIdenticalSplits() = runBlocking { + val dataset = Iris.load() + + val (trainA, testA) = dataset.split(splitRatio = 0.8, seed = 42L, stratified = true) + val (trainB, testB) = dataset.split(splitRatio = 0.8, seed = 42L, stratified = true) + + val labelsA = (0 until trainA.xSize).map { trainA.getY(it) } + (0 until testA.xSize).map { testA.getY(it) } + val labelsB = (0 until trainB.xSize).map { trainB.getY(it) } + (0 until testB.xSize).map { testB.getY(it) } + assertEquals(labelsA, labelsB) + + val firstTrainFeaturesA = trainA.getX(0).toList() + val firstTrainFeaturesB = trainB.getX(0).toList() + assertEquals(firstTrainFeaturesA, firstTrainFeaturesB) + } + + @Test + fun parserRejectsMalformedRowsNamingLineAndColumn() { + val unknownSpecies = assertFailsWith { + parseIrisCsv("5.1,3.5,1.4,0.2,Iris-unknown") + } + assertTrue("line 1" in unknownSpecies.message!!) + assertTrue("Iris-unknown" in unknownSpecies.message!!) + + val badNumber = assertFailsWith { + parseIrisCsv("abc,3.5,1.4,0.2,Iris-setosa") + } + assertTrue("sepalLength" in badNumber.message!!) + + val wrongFieldCount = assertFailsWith { + parseIrisCsv("5.1,3.5,1.4,Iris-setosa") + } + assertTrue("expected 5" in wrongFieldCount.message!!) + } + + private fun classCounts(dataset: Dataset): Map = + (0 until dataset.xSize).groupingBy { idx -> dataset.getY(idx) }.eachCount() + + private fun speciesOfCsvLine(lineIndex: Int): String = + IRIS_CSV.lines().filter { it.isNotBlank() }[lineIndex].substringAfterLast(",") +} From 0dd46208711708063d1ac38ecfb521526efd8451 Mon Sep 17 00:00:00 2001 From: michalharakal Date: Mon, 24 Aug 2026 20:02:21 +0200 Subject: [PATCH 2/2] fix(data): interpolate classNames in Iris unknown-species error message --- .../src/commonMain/kotlin/sk/ainet/data/iris/IrisData.kt | 2 +- .../src/jvmTest/kotlin/sk/ainet/data/iris/IrisDatasetTest.kt | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/skainet-data/skainet-data-simple/src/commonMain/kotlin/sk/ainet/data/iris/IrisData.kt b/skainet-data/skainet-data-simple/src/commonMain/kotlin/sk/ainet/data/iris/IrisData.kt index bd2820b80..811c5e46b 100644 --- a/skainet-data/skainet-data-simple/src/commonMain/kotlin/sk/ainet/data/iris/IrisData.kt +++ b/skainet-data/skainet-data-simple/src/commonMain/kotlin/sk/ainet/data/iris/IrisData.kt @@ -212,7 +212,7 @@ internal fun parseIrisCsv(csv: String): List { val speciesField = fields[featureCount].trim() val label = Iris.classNames.indexOf(speciesField) require(label >= 0) { - "Iris CSV line $lineNumber has unknown species '$speciesField'; expected one of $Iris.classNames" + "Iris CSV line $lineNumber has unknown species '$speciesField'; expected one of ${Iris.classNames}" } IrisSample( diff --git a/skainet-data/skainet-data-simple/src/jvmTest/kotlin/sk/ainet/data/iris/IrisDatasetTest.kt b/skainet-data/skainet-data-simple/src/jvmTest/kotlin/sk/ainet/data/iris/IrisDatasetTest.kt index b20e1d245..6b374efaa 100644 --- a/skainet-data/skainet-data-simple/src/jvmTest/kotlin/sk/ainet/data/iris/IrisDatasetTest.kt +++ b/skainet-data/skainet-data-simple/src/jvmTest/kotlin/sk/ainet/data/iris/IrisDatasetTest.kt @@ -133,6 +133,8 @@ class IrisDatasetTest { } assertTrue("line 1" in unknownSpecies.message!!) assertTrue("Iris-unknown" in unknownSpecies.message!!) + // The valid class names must actually be interpolated into the message. + assertTrue("Iris-setosa" in unknownSpecies.message!!) val badNumber = assertFailsWith { parseIrisCsv("abc,3.5,1.4,0.2,Iris-setosa")