Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions Numerics/NumericTest/ComplexNumberTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Xunit.Sdk;
using System;
using CSharpNumerics.Numerics.Objects;
using System.Drawing;
using CSharpNumerics.Numerics;
Expand Down Expand Up @@ -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);
Expand Down
22 changes: 21 additions & 1 deletion Numerics/NumericTest/TimeserieValidationTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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()
{
Expand All @@ -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);
Expand Down
28 changes: 0 additions & 28 deletions Numerics/Numerics.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
30 changes: 29 additions & 1 deletion Numerics/Numerics/ML/Clustering/MonteCarloClustering.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -62,7 +63,13 @@ public class MonteCarloClustering
/// <summary>Number of Monte Carlo iterations. Default 100.</summary>
public int Iterations { get; set; } = 100;

/// <summary>Optional random seed for reproducibility.</summary>
/// <summary>
/// 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.
/// </summary>
public int? Seed { get; set; }

/// <summary>Confidence level for reported intervals. Default 0.95.</summary>
Expand Down Expand Up @@ -120,6 +127,7 @@ public MonteCarloClusteringResult RunBootstrap(

// 3. Fit & predict
var modelClone = algorithm.Clone();
SeedClone(modelClone, rng);
VectorN labels = modelClone.FitPredict(xBoot);

// 4. Score
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -304,6 +313,25 @@ public MonteCarloClusteringResult RunExperiment(
/// <summary>
/// Bootstrap sampling: draw n indices from [0, n) with replacement.
/// </summary>
/// <summary>
/// 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.
/// </summary>
private void SeedClone(IClusteringModel modelClone, RandomGenerator rng)
{
if (!Seed.HasValue) return;
if (modelClone is IHasHyperparameters seedable)
seedable.SetHyperParameters(new Dictionary<string, object>
{
["Seed"] = rng.NextInt(int.MaxValue)
});
}

private static int[] SampleWithReplacement(RandomGenerator rng, int populationSize, int sampleSize)
{
var result = new int[sampleSize];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 7 additions & 1 deletion Numerics/Numerics/Statistics/Data/Series.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading