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); } }