diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..9fc3bc8
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,43 @@
+name: CI
+
+# Verifies every branch and pull request. Releases are handled separately by
+# publish.yml, which runs on version tags — this workflow never publishes.
+#
+# It exists because nothing else runs the test suite. publish.yml builds and
+# packs the library project directly and never invokes dotnet test, so until now
+# a release could go out without a single test having been executed. The suite
+# also cannot be relied on to run locally: a Windows application control policy
+# can block locally built test assemblies from loading.
+on:
+ push:
+ branches:
+ - '**'
+ tags-ignore:
+ - 'v*'
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ build-and-test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: |
+ 8.0.x
+ 10.0.x
+
+ - name: Restore
+ run: dotnet restore Numerics/Numerics.sln
+
+ - name: Build
+ run: dotnet build Numerics/Numerics.sln -c Release --no-restore
+
+ - name: Test
+ run: dotnet test Numerics/NumericTest/NumericTest.csproj -c Release --no-build
diff --git a/Numerics/NumericTest/ComplexNumberTests.cs b/Numerics/NumericTest/ComplexNumberTests.cs
index 1e5eaee..26d2b23 100644
--- a/Numerics/NumericTest/ComplexNumberTests.cs
+++ b/Numerics/NumericTest/ComplexNumberTests.cs
@@ -1,4 +1,5 @@
using Xunit.Sdk;
+using System;
using CSharpNumerics.Numerics.Objects;
using System.Drawing;
using CSharpNumerics.Numerics;
@@ -90,6 +91,17 @@ public void TestComplexDivision()
[TestMethod]
public void TestMandelbrot()
{
+ // System.Drawing.Common is Windows-only from .NET 6 onward, and CI
+ // runs on Linux. The Mandelbrot maths is exercised by the complex
+ // number tests above; this one only checks that it can be rendered,
+ // so it is reported as inconclusive off Windows rather than being
+ // silently skipped or forcing the whole suite onto a Windows runner.
+ if (!OperatingSystem.IsWindows())
+ {
+ Assert.Inconclusive("System.Drawing.Common requires Windows.");
+ return;
+ }
+
var maxValueExtent = 2.0;
var bitmap = new Bitmap(600, 600);
diff --git a/Numerics/NumericTest/TimeserieValidationTests.cs b/Numerics/NumericTest/TimeserieValidationTests.cs
index bb43321..dbe6e1c 100644
--- a/Numerics/NumericTest/TimeserieValidationTests.cs
+++ b/Numerics/NumericTest/TimeserieValidationTests.cs
@@ -1,4 +1,6 @@
using CSharpNumerics.ML;
+using System;
+using System.Linq;
using CSharpNumerics.ML.CrossValidators;
using CSharpNumerics.ML.Models.Classification;
using CSharpNumerics.ML.Models.Regression;
@@ -70,6 +72,24 @@ public void ShuffleSplitCV_Should_WorkOnRegressionData()
Assert.IsTrue(result.BestScore > -1);
}
+ [TestMethod]
+ public void SeriesFromCsv_ColsMustAlignWithData()
+ {
+ // Regression test for the off-by-one that made every Series-based
+ // cross-validation train on a leaked target and validate against a
+ // feature: Cols was header.Skip(1), so IndexOf(Cols, name) pointed
+ // one column left of the truth in Data.
+ CsvTestDataGenerator.GenerateClassificationCsv("cols_alignment.csv");
+ var df = Series.FromCsv("cols_alignment.csv");
+
+ Assert.AreEqual(df.Data.Length, df.Cols.Length,
+ "every data column must have a name");
+
+ var target = df.Data[Array.IndexOf(df.Cols, "Target")];
+ Assert.IsTrue(target.All(v => v == 0.0 || v == 1.0),
+ "the column Cols calls 'Target' must hold the class labels, not a feature");
+ }
+
[TestMethod]
public void StratifiedKFoldCV_Should_WorkOnClassificationData()
{
@@ -85,7 +105,7 @@ public void StratifiedKFoldCV_Should_WorkOnClassificationData()
var cv = new StratifiedKFoldCrossValidator(pipelineGrid, folds: 5);
- var result = cv.Run(df.ToMatrix(2), new VectorN(df.Data[colIndex]));
+ var result = cv.Run(df.ToMatrix(colIndex), new VectorN(df.Data[colIndex]));
Assert.IsTrue(result.BestScore > 0);
diff --git a/Numerics/Numerics.sln b/Numerics/Numerics.sln
index d3d3f40..8722a23 100644
--- a/Numerics/Numerics.sln
+++ b/Numerics/Numerics.sln
@@ -7,12 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CSharpNumerics", "Numerics\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NumericTest", "NumericTest\NumericTest.csproj", "{26D48AFB-281B-452F-B8C8-5BCCD105925C}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSharpNumerics.Engines", "CSharpNumerics.Engines\CSharpNumerics.Engines.csproj", "{55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}"
-EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Numerics", "Numerics", "{35409048-A951-1400-ED33-5FB42D2B3E23}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NumericTest.Engines", "NumericTest.Engines\NumericTest.Engines.csproj", "{18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}"
-EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -47,30 +43,6 @@ Global
{26D48AFB-281B-452F-B8C8-5BCCD105925C}.Release|x64.Build.0 = Release|Any CPU
{26D48AFB-281B-452F-B8C8-5BCCD105925C}.Release|x86.ActiveCfg = Release|Any CPU
{26D48AFB-281B-452F-B8C8-5BCCD105925C}.Release|x86.Build.0 = Release|Any CPU
- {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Debug|x64.ActiveCfg = Debug|Any CPU
- {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Debug|x64.Build.0 = Debug|Any CPU
- {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Debug|x86.ActiveCfg = Debug|Any CPU
- {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Debug|x86.Build.0 = Debug|Any CPU
- {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Release|Any CPU.Build.0 = Release|Any CPU
- {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Release|x64.ActiveCfg = Release|Any CPU
- {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Release|x64.Build.0 = Release|Any CPU
- {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Release|x86.ActiveCfg = Release|Any CPU
- {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Release|x86.Build.0 = Release|Any CPU
- {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Debug|x64.ActiveCfg = Debug|Any CPU
- {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Debug|x64.Build.0 = Debug|Any CPU
- {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Debug|x86.ActiveCfg = Debug|Any CPU
- {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Debug|x86.Build.0 = Debug|Any CPU
- {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Release|Any CPU.Build.0 = Release|Any CPU
- {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Release|x64.ActiveCfg = Release|Any CPU
- {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Release|x64.Build.0 = Release|Any CPU
- {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Release|x86.ActiveCfg = Release|Any CPU
- {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Numerics/Numerics/ML/Clustering/MonteCarloClustering.cs b/Numerics/Numerics/ML/Clustering/MonteCarloClustering.cs
index bc186a6..b34a2ca 100644
--- a/Numerics/Numerics/ML/Clustering/MonteCarloClustering.cs
+++ b/Numerics/Numerics/ML/Clustering/MonteCarloClustering.cs
@@ -1,4 +1,5 @@
using CSharpNumerics.ML.Clustering.Interfaces;
+using CSharpNumerics.ML.Models.Interfaces;
using CSharpNumerics.ML.Clustering.Results;
using CSharpNumerics.ML.Scalers.Interfaces;
using CSharpNumerics.Numerics.Objects;
@@ -62,7 +63,13 @@ public class MonteCarloClustering
/// Number of Monte Carlo iterations. Default 100.
public int Iterations { get; set; } = 100;
- /// Optional random seed for reproducibility.
+ ///
+ /// Optional random seed for reproducibility. When set it governs the whole
+ /// run: bootstrap sampling, and a per-iteration seed handed to each model
+ /// clone that accepts one, so an unseeded KMeans no longer re-randomises its
+ /// initialisation between what should be identical runs. Models that take no
+ /// seed, like DBSCAN, ignore it.
+ ///
public int? Seed { get; set; }
/// Confidence level for reported intervals. Default 0.95.
@@ -120,6 +127,7 @@ public MonteCarloClusteringResult RunBootstrap(
// 3. Fit & predict
var modelClone = algorithm.Clone();
+ SeedClone(modelClone, rng);
VectorN labels = modelClone.FitPredict(xBoot);
// 4. Score
@@ -257,6 +265,7 @@ public MonteCarloClusteringResult RunExperiment(
{
var modelClone = algorithm.Clone();
SetK(modelClone, k);
+ SeedClone(modelClone, rng);
VectorN labels = modelClone.FitPredict(xBoot);
double score = evaluator.Score(xBoot, labels);
@@ -304,6 +313,25 @@ public MonteCarloClusteringResult RunExperiment(
///
/// Bootstrap sampling: draw n indices from [0, n) with replacement.
///
+ ///
+ /// Hands a model clone a seed derived from the run's generator, so that a
+ /// seeded Monte Carlo run is deterministic end to end. Without this the
+ /// bootstrap sampling was reproducible but the model's own initialisation
+ /// was not, and RunBootstrap on an unseeded KMeans differed between
+ /// identical runs whenever k-means landed in a different local optimum.
+ /// Derived per iteration rather than fixed, so replicates stay independent.
+ /// No-op when the run is unseeded or the model takes no hyperparameters.
+ ///
+ private void SeedClone(IClusteringModel modelClone, RandomGenerator rng)
+ {
+ if (!Seed.HasValue) return;
+ if (modelClone is IHasHyperparameters seedable)
+ seedable.SetHyperParameters(new Dictionary
+ {
+ ["Seed"] = rng.NextInt(int.MaxValue)
+ });
+ }
+
private static int[] SampleWithReplacement(RandomGenerator rng, int populationSize, int sampleSize)
{
var result = new int[sampleSize];
diff --git a/Numerics/Numerics/ML/CrossValidators/StratifiedKFoldCrossValidator.cs b/Numerics/Numerics/ML/CrossValidators/StratifiedKFoldCrossValidator.cs
index 218c1e6..3507129 100644
--- a/Numerics/Numerics/ML/CrossValidators/StratifiedKFoldCrossValidator.cs
+++ b/Numerics/Numerics/ML/CrossValidators/StratifiedKFoldCrossValidator.cs
@@ -68,6 +68,23 @@ public CrossValidationResult Run(Matrix X, VectorN y)
}
+ // Folds are filled class by class, each class restarting at fold 0,
+ // so a class with fewer members than Folds leaves the later folds
+ // short and a data set of entirely small classes leaves them empty.
+ // Without this the failure surfaces further down as an opaque
+ // "values cannot be null or empty" from the VectorN constructor,
+ // which says nothing about the cause.
+ for (int fold = 0; fold < Folds; fold++)
+ {
+ if (foldIndices[fold].Count == 0)
+ throw new InvalidOperationException(
+ $"Fold {fold + 1} of {Folds} is empty, so it cannot be validated against. " +
+ $"Samples: {n}, classes: {classIndices.Count}, " +
+ $"smallest class: {classIndices.Values.Min(c => c.Count)} member(s). " +
+ "Stratified folds need at least as many members in every class as there are folds; " +
+ "reduce the fold count or supply more samples per class.");
+ }
+
for (int fold = 0; fold < Folds; fold++)
{
var testIdx = foldIndices[fold].OrderBy(x => x).ToArray();
diff --git a/Numerics/Numerics/Statistics/Data/Series.cs b/Numerics/Numerics/Statistics/Data/Series.cs
index 6e5354c..e6aac49 100644
--- a/Numerics/Numerics/Statistics/Data/Series.cs
+++ b/Numerics/Numerics/Statistics/Data/Series.cs
@@ -113,6 +113,12 @@ public static Series FromCsv(
}
var index = Enumerable.Range(0, rows).ToArray();
- return new Series(index, data, [.. header.Skip(1)], groups);
+
+ // Cols must name exactly the columns that ended up in Data, in the same
+ // order. The previous header.Skip(1) was copied from TimeSeries, where
+ // column 0 is the time axis and really is excluded from Data - here it
+ // shifted every name one step left, so a lookup like
+ // IndexOf(Cols, "Target") pointed at the wrong data column.
+ return new Series(index, data, [.. featureIdx.Select(i => header[i])], groups);
}
}