Skip to content

NumSharp 0.80.0 on net8.0 / net10.0: bug fixes, a scikit-learn oracle, faster and leaner estimators - #3

Open
Nucs wants to merge 12 commits into
SciSharp:masterfrom
Nucs:numsharp-0.80
Open

Nucs wants to merge 12 commits into
SciSharp:masterfrom
Nucs:numsharp-0.80

Conversation

@Nucs

@Nucs Nucs commented Nov 9, 2026

Copy link
Copy Markdown
Member

Hi @Oceania2018, this moves Bigtree.Algorithm to NumSharp 0.80.0 on net8.0 / net10.0. The details are below.
After this PR I will contribute to CherubNLP, also improving it.
Kindly review and merge.

Summary

This moves Bigtree.Algorithm from NumSharp 0.30.0 / netstandard2.0 to NumSharp 0.80.0 on net8.0 and net10.0, and
proposes the result as version 0.5.0.

  • Every member CherubNLP calls keeps its signature and its numbers. MultinomialNaiveBayes returns 0.4.0's
    log-probabilities to the last bit.
  • The two misspelled classes now have their correct names: MultinomiaNaiveBayes → MultinomialNaiveBayes and
    MultinomiaNaiveBayesModel → MultinomialNaiveBayesModel. Saved JSON models still load.
  • The four empty classes (GaussianNaiveBayes, BernoulliNaiveBayes, KnnModel, LinearRegressionModel) are
    implemented. They take and return NumSharp NDArrays with scikit-learn's shape: Fit, Predict, PredictProba,
    Score, and fitted attributes as properties.
  • The bugs listed below are fixed.
  • The test suite actually asserts results now. As cloned, 6 of 20 tests passed and none of them checked a result.
    There are now 73 unit tests, plus 12 oracle tests that compare each estimator with scikit-learn, NumPy or SciPy on
    the same arrays.

Verified on Windows 11 and Ubuntu 24.04 (WSL2), for net8.0 and net10.0:

  • build: 0 warnings, 0 errors
  • unit tests: 73/73
  • oracle tests: 12/12, with NumPy 2.4.2, SciPy 1.16.3 and scikit-learn 1.8.0

Per model

Model 0.4.0 This PR
MultinomialNaiveBayes (was MultinomiaNaiveBayes) rebuilt and copied the label's sample matrix on every CalCondProb call same members and bits; each feature value is counted once, in one vectorized pass
GaussianNaiveBayes empty class scikit-learn's GaussianNB, bit-identical (theta_, var_, class_prior_, epsilon_, every prediction output)
BernoulliNaiveBayes empty class scikit-learn's BernoulliNB
KnnModel empty class distances from one matrix product, np.argpartition, votes; queries in 32 MB blocks
LinearRegressionModel empty class centered normal equations solved with np.linalg.solve (fully managed, no native dependency)
NetworkModel Neuron / Dendrite objects, per-sample training one weight matrix per layer, mini-batch backpropagation, seeded from NumPy's generator
DecisionTree.Tree (ID3) entropy of "first label vs the rest" multi-class entropy; each column is encoded once, and the tree grows over integer codes
HMM / Baum-Welch the scaled forward/backward pair did not run; log-space loops fixed; Baum-Welch runs on Rabiner's scaled forward-backward
SVM (libsvm port) per-row prediction the same SMO solver, bug fixes, NDArray entry points, and batch prediction from one kernel matrix
CRF (CRFSharp) unchanged

Bugs fixed

  • SVM
    • RangeTransform mixed 0- and 1-based feature indices, and an empty catch hid the error.
    • Polynomial-kernel prediction used Degree where training used Gamma.
    • Model.Write kept 6 decimals, so a saved model predicted differently after loading.
    • The class-weight check warned about weights that were present.
  • HMM
    • The scaled forward/backward pair did not run: a ++N loop increment, and the first scale coefficient was never
      stored.
    • Baum-Welch's convergence value measured only the last sequence.
  • Neural network
    • The hidden error summed more than the next layer's deltas.
    • Every layer's update used the network input instead of that layer's own input.
  • Decision tree
    • The entropy only handled two labels.
    • A subtable lost every attribute to the left of the split.
    • Contradictory rows threw; they now end in a majority leaf.
  • Utilities and tests
    • Utils.CrossValFolds returned no folds.
    • The test data that 0.4.0's tests read from its author's disk is restored into data/: the NeedUmbrella table,
      a CRF corpus and its template.

Breaking changes

Change Impact Migration
netstandard2.0 → net8.0 / net10.0 NumSharp 0.80.0 targets net8.0+ only, so netstandard and .NET Framework consumers (CherubNLP today) cannot take 0.5.0 retarget to net8.0 or later
NumSharp 0.30.0 → 0.80.0 code that uses NumSharp types directly gets 0.80.0 NumSharp's 0.80.0 release notes
MultinomiaNaiveBayes → MultinomialNaiveBayes, MultinomiaNaiveBayesModel → MultinomialNaiveBayesModel source that names the classes (CherubNLP: 3 lines) use the new names; saved JSON models load unchanged (the same properties)
NetworkModel: Neuron / Dendrite removed; NeuralLayer is (Count, Name); Train(..., batchSize); Build(seed) code that walked neurons read Weights, one matrix per layer
Utils.CrossValFolds returns NDArray[] folds it returned none -
Utils.ReadCsv numbers labels in ascending order label codes can differ look labels up through the returned classes
NDArray.Normalize() (NumSharp 0.30) gone in NumSharp 0.80 Utils.Normalize(X)
The NumSharp estimators implement IDisposable their arrays live in unmanaged memory using var model = ... (the finalizer also releases them)
A null cell (ID3) or a null label (MultinomialNaiveBayes) ArgumentException naming the row -

Same numbers as scikit-learn

Bigtree.Algorithm.Tests.Oracle embeds CPython through NumSharp.Interop.pythonnet and checks:

  • GaussianNaiveBayes against GaussianNB: bit-identical.
  • BernoulliNaiveBayes: counts and log-probabilities bit-identical; probabilities to 1e-10.
  • KnnModel against KNeighborsClassifier: the same neighbours, votes and labels; distances to 1e-12.
  • LinearRegressionModel against LinearRegression: coefficients to 1e-10.
  • The SVM against SVC: the same support vectors, dual coefficients, intercepts and one-vs-one decision values (to
    1e-9). On the unit tests' problems, every SVM type × kernel scores exactly SVC / NuSVC's accuracy.
  • NetworkModel against NumPy mini-batch backpropagation from the same weights: to 1e-12.
  • Baum-Welch against a NumPy / SciPy log-space reference: to 1e-9.
  • ID3's gains against scipy.stats.entropy.
  • Utils.CrossValFolds against NumPy's default_rng(seed).permutation: element for element.

The oracle needs an interpreter with NumPy, SciPy and scikit-learn: set NUMSHARP_PYTHONNET_PYTHON. Without it the
oracle tests report Inconclusive, and they fail under CI=true. The unit tests need no Python.

Performance and memory

benchmarks/ holds .NET 10 file-based apps. Each one runs 0.4.0's code, copied verbatim, against this branch in one
process, with interleaved best-of-N rounds:

Benchmark Speed-up
CherubNLP's training pass through MultinomialNaiveBayes ×47 to ×1,147
Baum-Welch ×20 to ×58
SVM: batch prediction of a test set vs 0.4.0's per-row prediction ×1.6 to ×7.0, the same labels

Peaks measured on large inputs (process peak working set, which includes the input):

Workload Time Peak memory
MultinomialNaiveBayes training pass, 20,000 sentences × 2,000 features 0.12-0.15 s 376 MB (320 MB of it is the input)
KNN, 8,000 queries × 8,000 samples × 30 features ~0.95 s 170 MB
GaussianNaiveBayes fit + PredictProba, 100,000 × 50, 10 classes 0.26-0.54 s 280 MB
LinearRegressionModel.Fit, 200,000 × 100 0.38-0.49 s 277 MB (160 MB of it is the input)
ID3, 50,000 rows × 8 attributes, one Learn in a fresh process 0.11 s 85 MB

How the estimators use NumSharp:

  • NumSharp costs about 1.5-3 µs per call, so the estimators work on whole arrays and never call NumSharp per element.
  • Inherently sequential code stays scalar: the HMM recursions, the SMO solver, CRF decoding, and growing the ID3 tree.
  • Element-wise formulas are one fused np.evaluate pass, or an NDExpr program compiled once, with the bits of the
    ufunc chain they replace.
  • Temporaries return to NumSharp's buffer pool through NDScope / [NDScoped]. These are woven at build time by
    NumSharp.Build, a build-only dependency (PrivateAssets="all").

Notes for the maintainers

  • Version: 0.5.0 is proposed. NuGet has 0.1.0 to 0.4.0.
  • CherubNLP compiles against 0.5.0 once it targets net8.0 and uses the two new class names (three lines in
    NaiveBayesClassifier.cs). Its saved models load unchanged. Its training pass is the MultinomialNaiveBayes
    benchmark above. It targets netstandard2.0 and references Bigtree 0.4.0 today. Once this is merged, I will open the
    CherubNLP side: net8.0 / net10.0, Bigtree.Algorithm 0.5.0 and the new names.
  • Packaging: GeneratePackageOnBuild is kept as it was. With it, dotnet pack -o <dir> fails with NU5026, while
    plain dotnet pack works. PackageLicenseUrl, which NuGet deprecated, became PackageLicenseExpression Apache-2.0
    (the same license).
  • License headers: 21 source files still carry GPL-3 headers from earlier code, while the repository and the
    package are Apache-2.0. This PR leaves them as they are.
  • Commits: each commit message records what it changed, how it was verified, and its measurements.
The 11 commits
  • 3226aad NumSharp 0.80.0 on net8.0/net10.0: whole-array Naive Bayes, KNN, linear regression, MLP, ID3 and
    Baum-Welch; libsvm-exact SVM; every test runs
  • 88f05c6 Baum-Welch: a NumSharp E/M step above a measured size gate
  • 95f5aad the scikit-learn oracle (12 tests), SVM batch prediction as one fused kernel pass, benchmarks against
    0.4.0, README
  • 010fa28 Baum-Welch on Rabiner's scaled forward-backward (×20-×58 against 0.4.0)
  • 53ab91c KNN and SVM batch prediction in 32 MB blocks; GaussianNB frees each class's temporaries
  • f5ce17b ID3: each column encoded once, the tree grown over integer codes
  • ec3072c Multinomial Naive Bayes: one vectorized counting pass per feature value
  • b672cbd the neural network's element-wise formulas as compiled NDExpr programs
  • cf269b9 linear regression in 32 MB blocks, fused finiteness checks, GaussianNB's distance compiled
  • ae45740 benchmark notes: SVM batch prediction is bound by float64 exp
  • 0be5e1d MultinomialNaiveBayes / MultinomialNaiveBayesModel: the misspelled names corrected

How to check

dotnet build Bigtree.sln
dotnet test Bigtree.Algorithm.UnitTest
NUMSHARP_PYTHONNET_PYTHON=/path/to/python dotnet test Bigtree.Algorithm.Tests.Oracle   # NumPy, SciPy, scikit-learn
dotnet run -c Release benchmarks/MultinomialNaiveBayes.cs   # also BaumWelch.cs, SvmBatchPrediction.cs, DecisionTree.cs

Nucs added 12 commits November 8, 2026 22:33
…NN, linear regression, MLP, ID3 and Baum-Welch; libsvm-exact SVM; every test runs (65/65 on both frameworks)

Bigtree.Algorithm 0.4.0 (2021-04, NumSharp 0.30.0, netstandard2.0, tests on netcoreapp2.1) used NumSharp in two
places only: Utils.ReadCsv (np.array + reshape) and the neural network, which indexed NDArrays one element at a
time per neuron. Gaussian/Bernoulli Naive Bayes, KNN, linear regression and the CNN were empty classes. As cloned,
6 of 20 tests passed (run on .NET 10 by roll-forward), and none of the 6 checked a result.

Design (decided with the owner on 2026-11-08, decision pages): keep every member CherubNLP calls (its only
consumer: MultinomiaNaiveBayes, the SVM, Probability/Feature, CRFLite.Utils, Shuffle/Split) source-compatible with
bit-identical numbers; give everything else an NDArray-first API with scikit-learn's semantics (Fit / Predict /
PredictProba / Score); NumSharp 0.80.0 from nuget.org; restructure each algorithm into whole-array operations,
because NumSharp spends ~1.5-3 us per call (a 5-element op costs ~100 scalar multiply-adds), so per-element or
per-sample NumSharp calls are a regression, not an optimization.

Build
- Bigtree.Algorithm: net8.0;net10.0 (NumSharp 0.80.0 ships only those), NumSharp 0.80.0, NumSharp.Build 0.80.0
  (PrivateAssets=all: weaves [NDScoped] so temporaries return to the pool at method exit), version 0.5.0,
  PackageLicenseExpression Apache-2.0 (PackageLicenseUrl is deprecated, NU5125), XML docs shipped.
- .editorconfig silences CS1570/1572/1573/1587 for HiddenMarkovModel/MathUtils only: the ported statistics
  library's doc comments (unescaped '<', stale <param> tags) surface once the XML docs are generated.
- Tests: net8.0;net10.0, MSTest 3.8.3 (as NumSharp); MatplotlibCS (plotted from a path on the author's disk) and
  Microsoft.Extensions.Configuration (read a Settings/*.json never committed) removed.
- Console: net10.0 tour of the estimators on the seeds dataset.
- Solution builds with 0 warnings, 0 errors.

Estimators (NumSharp)
- MultinomiaNaiveBayes: same public API; CalCondProb reads a per-value (labels, features) table built ONCE by
  onehot(labels).T @ (X == value) (labels via np.unique on a text array). 0.4.0 filtered FeatureSet and copied the
  label's matrix on EVERY call, so CherubNLP's training pass cost O(labels * rows * features^2). Measured
  (prototype, Release): 300x100x4 52.8 ms -> 0.64 ms (x83), 1000x300x5 829.8 ms -> 2.91 ms (x285); all
  log-probabilities bit-identical (counts are exact integers in float64, same formula order). CalPosteriorProb
  builds the dictionary key only for features equal to 1 (0.4.0 formatted one per feature). Thread-safe lazy tables
  (lock); rebuilt when FeatureSet is another list or its Count changed (documented footgun: in-place row edits).
- GaussianNaiveBayes (was empty): scikit-learn 1.8 GaussianNB operation for operation - per-class np.mean/np.var,
  epsilon = var_smoothing * max(var(X, 0)), the per-class jll loop, stack(...).T layout, scikit-learn's own
  log1p-based _logsumexp (NDHelpers.LogSumExp). Priors validated like sklearn. Text labels work.
- BernoulliNaiveBayes (was empty): scikit-learn BernoulliNB (binarize, alpha, fit_prior, class_prior); counts =
  onehot.T @ X, jll = X @ (log p - log(1-p)).T + log prior + sum log(1-p).
- KnnModel (was empty): brute force |x|^2 - 2 x.t + |t|^2 (one matmul), np.argpartition for the k nearest,
  one-hot vote; KNeighbors returns sorted distances/indices; vote ties -> first class (sklearn).
- LinearRegressionModel (was empty): centered normal equations solved by np.linalg.solve (managed LU, no native
  backend), multi-output, R^2 Score with r2_score's constant-target rule; collinear features -> InvalidOperation
  (lstsq/SVD needs NumSharp's OpenBLAS package; documented conditioning footgun).
- NetworkModel: one NDArray weight matrix per layer, mini-batch backprop as matrix products (batchSize, default 32;
  1 = per-sample SGD exactly, verified against a scalar implementation to 1e-12), Predict over all rows at once,
  Build(seed) uses np.random.default_rng (NumPy's PCG64). Fixes 0.4.0's two backprop bugs: the hidden error summed
  deltas of the wrong layers, and every layer's update used the raw input x instead of its own input. Neuron and
  Dendrite removed (no outside user); NeuralLayer = (Count, Name). Seeds 4-fold CV: 97.5% train / 95.2% test in 2 s
  (0.4.0's own commit reported 96.75 / 89.50 before the bugs).
- MathFunctions.Function: NDArray overloads of Sigmoid / SigmoidDerivative.
- Utils: ReadCsv = np.loadtxt + np.unique (labels numbered in ascending value order; culture-invariant);
  CrossValFolds(N, n_folds, seed) returns NDArray[] index folds from Generator.permutation (0.4.0's body was
  commented out and returned no folds); Normalize = per-column min-max (what NumSharp 0.30's NDArray.Normalize did).
- DecisionTree (ID3): each column becomes a NumSharp text array; np.unique + np.bincount count the whole
  (value x label) table per attribute in one pass; multi-class Shannon entropy (0.4.0 used "first row's label vs
  rest" for attributes but not for the table); log2 taken as log/log(2) like Math.Log(p, 2) so two-label gains are
  unchanged; edges in first-appearance order. Fixes: subtables kept only the columns RIGHT of the split
  (Skip(TableIndex + 1)); contradictory rows now end in a majority leaf instead of an IndexOutOfRange.
- HMM Baum-Welch (discrete): forward/backward recursions stay scalar per sequence (sequential in t, N-sized), the
  E-step (gamma (rows, N), ksi (transitions, N, N)) and the M-step (pi, A by log-sum-exp; B = exp(gamma - max).T @
  onehot(obs)) run on NumSharp over all sequences stacked, one [NDScoped] call per iteration. Matches 0.4.0's scalar
  re-estimation (verbatim reference in the tests) to 1e-9, weights included. Fixes: the convergence log-likelihood
  was reset per sequence (only the last one counted) - now Accord.NET's log(sum of likelihoods) / K; validation of
  empty input. The continuous variant keeps the shared mLogGamma/mLogKsi scratch.
- ForwardBackwardAlgorithm: the scaled Backward looped "for (i = 0; i < N; ++N)" (never ended, then overran); the
  scaled Forward never stored scale_vector[0], so its log-likelihood was always -inf and Backward divided by 0.

SVM (libsvm 3.x port; solver stays scalar)
- RangeTransform.Compute sized its arrays by MaxIndex as if 0-based and swallowed the overflow of libsvm's 1-based
  last feature in an empty catch; now covers max(MaxIndex - 1, largest index), so 0-based (CherubNLP, unchanged file
  length) and 1-based problems both scale.
- Kernel.KernelFunction (the PREDICTION kernel) computed POLY as (Degree * <x,y> + coef0)^degree; training and libsvm
  use Gamma. Every polynomial prediction used another kernel than the trained one.
- Model.Write wrote every real as 0.000000 (gamma, coef0, rho, probA/B, coefficients, SV values): a saved+reloaded
  model (CherubNLP's SaveModel/LoadModel) had other coefficients, any below 5e-7 became 0. Now "R" (round-trip, as
  libsvm's %.17g); old files read as before. Precomputed serial written as an integer ("R" on an int throws).
- svm_train warned "class label X specified in weight is not found" for every class WITHOUT a weight (inverted
  libsvm loop), i.e. on every training with default parameters; now only for a weight whose label is no class.
- NDArray entry points: Problem.FromNDArray (dense, 1-based, zeros kept so scaling reaches them),
  Prediction.DecisionValues / PredictLabels (whole batch: kernel matrix X @ SV.T on NumSharp, per class pair two
  matrix-vector products; agree with the per-row path to rounding), PredictLabelsProbability (per-row Platt
  coupling, NDArray in/out). 0-based models refuse the matrix path (InvalidOperation), PRECOMPUTED -> NotSupported.
- Evidence: on the tests' exact scaled problems, all 16 {C_SVC, NU_SVC} x {linear, poly, rbf, sigmoid} x
  {2-class, 8-class} configurations give scikit-learn 1.8.0's (libsvm) test accuracy AND support-vector count
  exactly. 0.4.0's tests expected 100% for every kernel; POLY's true accuracy here is 88%/51% (C_SVC) and 96%/81%
  (NU_SVC). ClassificationTests now pin scikit-learn's numbers.

Tests (65, both frameworks; 0.4.0: 6/20 passing, none asserting a result)
- Restored data: DecisionTree.csv = WolfgangOfner/DecisionTree's NeedUmbrella table (Quinlan's 14 days, labels
  inverted) - TestID3's expected "OUTLOOK -- rain --> WIND -- weak --> NO" is right for it; a 20-sentence
  tab-separated NER corpus + CRF++ template for the CRF tests (0.4.0 read C:\Users\haipi\...).
- New: NaiveBayesTests (Multinomial bit-identical to a verbatim 0.4.0 copy, sklearn docstring examples via
  np.random.seed parity), KNNTest, LinearRegression ModelTest, NetworkModelTest (AND gate, batch-1 = scalar
  backprop, mini-batch = mean update), seeds CV, UtilsTests, BaumWelchTests (scalar reference, EM monotonicity,
  scaled forward/backward), SVM NDArrayTests (batch == per-row for 4 types x 4 kernels), IO round-trips instead of
  never-committed golden files, decision-tree shape/multi-class/column fixes, CRF encode + decode.

Found in NumSharp 0.80.0 on the way (worked around here, to report): no tuple-axis reductions (np.sum(axis=(1,2)));
no bulk string[] export of a text array (ToArray<T> needs unmanaged T; AsStringArray decodes the legacy char form);
NumSharp.Build NDW003 refuses [NDScoped] on a method returning double[] (cannot hold an NDArray).
…observations x states^2, where it measures faster; below that it keeps 0.4.0's scalar loops - never slower than 0.4.0, 1.4-2.1x faster above the gate

3226aad moved the discrete Baum-Welch E/M step to NumSharp for every problem size. Measured afterwards against a
verbatim copy of 0.4.0's Run (10 iterations, Release, every implementation warmed 4x, paused 300 ms for tier-1,
warmed 4x again, then 10 interleaved rounds, best of 10 - the first, cruder measurement left methods at tier-0 and
was off by up to 2.8x), the NumSharp step pays a fixed ~100 calls x 1.5-3 us per iteration, which loses below about
20,000 (rows x N^2) elements:

  rows x N^2   before gate (always NumSharp)   after gate
  2,000        x0.36 (crude run)                x1.00 (scalar path, same code as 0.4.0)
  9,000        x0.91                            x0.98
  18,000       x0.94                            x1.05
  36,000       x1.64                            x2.04 (NumSharp path)
  50,000       x1.72                            x1.71
  64,000-1.28M x1.43-2.11                       x1.43-2.06

(rows = total observations over all sequences, N = states; "x" = 0.4.0 time / 0.5.0 time, higher is better.)

- BaumWelchLearning.Run validates, then dispatches on rows * N^2 < VectorizedMinimumWork (25,000, documented
  with the measurements) to RunScalar = 0.4.0's loops verbatim except the convergence log-likelihood fix (summed
  over all sequences once per iteration, not reset per sequence), which also fills mLogGamma / mLogKsi as before;
  otherwise RunVectorized (the NumSharp path of 3226aad, unchanged).
- Class remarks state the gate, the measured gain and which path fills the scratch arrays.
- Tests: MatchesTheScalarReEstimation and WeightedSequencesMatchTheScalarReEstimation are DataRows over both paths
  (750 x 2^2 scalar, 7,200 x 2^2 = 28,800 NumSharp) against the verbatim 0.4.0 re-estimation; 67/67 on net8.0 and
  net10.0.
… tests, both frameworks) - GaussianNaiveBayes bit-identical to GaussianNB; SVM batch prediction as one fused kernel pass + two matrix products (x1.5-4.7 vs per-row); in-repo benchmarks against 0.4.0's verbatim code; README

Oracle (Bigtree.Algorithm.Tests.Oracle, MSTest, net8.0;net10.0, NumSharp.Interop.pythonnet 0.80.0)
- Embeds the CPython named by NUMSHARP_PYTHONNET_PYTHON (the variable NumSharp's own pythonnet tests read; the
  interpreter needs numpy, scipy, scikit-learn). Unset: Inconclusive locally, failure under CI=true. PythonEnvironment
  / OracleTest adapted from NumSharp's examples/NumSharpPythonnetGhActionsCicd: ProgramName set for every interpreter
  (venv adoption), BeginAllowThreads, DoNotParallelize (one GIL), NoopFormatter shutdown, a fresh py module scope per
  test, and the LiveExports / LiveImports leak check after each test.
- Assertion helpers take the oracle value as object and copy a PyObject in (PyObject.ToNDArray): a dynamic py.x is
  converted by the codec on assignment only, not when passed as an argument (RuntimeBinderException otherwise).
- Results with ~/.claude/python (Python 3.12.12, NumPy 2.4.2, SciPy 1.16.3, scikit-learn 1.8.0):
  * GaussianNaiveBayes vs GaussianNB on iris: theta_, var_, class_prior_, class_count_, epsilon_,
    predict_joint_log_proba, predict_log_proba, predict_proba, predict - BIT-IDENTICAL (it repeats sklearn's ops incl.
    the log1p-based _logsumexp and the stack(...).T layout; NumSharp's reductions/ufuncs are NumPy's). Wine with fixed
    priors and var_smoothing 1e-3: var_ bit-identical, proba to 1e-12.
  * BernoulliNaiveBayes vs BernoulliNB on digits (binarize 8, alpha 0.5): feature_count_, feature_log_prob_,
    class_log_prior_ bit-identical; predict_log_proba to 1e-10 (sklearn's X @ W.T runs on BLAS); predict identical.
  * MultinomiaNaiveBayes: CherubNLP's log-probabilities = NumPy's evaluation of the same formula, bit-identical.
  * KnnModel vs KNeighborsClassifier(brute, k=7) on random data: identical neighbour indices, predict_proba, predict;
    distances to 1e-12.
  * LinearRegressionModel vs LinearRegression on diabetes: coef_ to 1e-10, intercept_/predict/R^2 to 1e-12.
  * SVM vs SVC(rbf, gamma 0.25, C 2, ovo) on iris scaled by Bigtree's RangeTransform: identical n_support_ and
    support_ indices, rho (= -intercept_) and |dual_coef_| and the ovo decision_function to 1e-9, identical labels.
  * NetworkModel vs NumPy mini-batch backprop from the same weights (seeds, 30 epochs, batch 8): weights to 1e-12.
  * Baum-Welch vs a NumPy/SciPy log-space re-estimation, both Run paths (scalar 8x40, NumSharp 80x100): pi, A, B to 1e-9.
  * ID3 gains vs scipy.stats.entropy; Utils.CrossValFolds = NumPy default_rng(123).permutation(210), element for element.

SVM batch prediction (Prediction.DecisionValues / PredictLabels on an NDArray)
- All class pairs' decision values are ONE matrix product: the pair coefficients go into a dense (support vectors,
  pairs) matrix, zero outside each pair's two classes. The first version sliced the kernel matrix per pair, and
  NumSharp's dot on those strided column views was the bottleneck (5000 x 988: ~200 ms with or without OpenBLAS).
- The kernels' elementwise part is one fused np.evaluate pass (NDExpr.Exp / Maximum / Power / Tanh): the unfused RBF
  chain made six passes and five matrix-sized temporaries. Measured in one process, interleaved, best of 10: fused
  result bit-identical; 1000x281 x1.14, 5000x988 x2.18, 10000x1999 x2.08.
- Votes: votes = positive @ (firsts - seconds) + column sums of seconds, one small product instead of strided
  per-pair column updates (the vote step now costs ~0.25 ms for 5000 rows).
- End to end vs per-row libsvm prediction (benchmarks/SvmBatchPrediction.cs, interleaved best of 10):
  281 SVs / 1000 rows x1.5, 988 / 5000 x2.9, 1999 / 10000 x4.7; labels identical in every case.
- OpenBLAS (NumSharp.Interop.OpenBLAS 0.80.0, OpenBlasEngine.Enable(), 24 threads) was tried: no reliable gain on
  this shared machine (~25% busy with other work), so the library stays managed-only; the products are contiguous
  and would route to gemm once a consumer enables the backend.

Benchmarks (benchmarks/, .NET 10 file-based apps, 0.4.0's code copied verbatim as the baseline)
- MultinomialNaiveBayes.cs: CherubNLP's training pass x37 (300x100x4), x216 (1000x300x5), x194 (2000x500x6).
- BaumWelch.cs: x1.00-1.05 below the 25,000 gate (same code), x1.60-2.25 above (up to 1.28M rows x N^2).
- SvmBatchPrediction.cs: as above. benchmarks/README.md has the tables and the method.

README: models table (how each computes), a quick example (compiled and run verbatim: network 96.7%, SVM 93.3% train
accuracy on seeds), the scikit-learn parity list, how to run unit vs oracle tests, performance summary, memory /
ownership rules, and every change from 0.4.0 (API and behaviour).

Package check: dotnet pack -c Release -> lib/net8.0 + lib/net10.0 (dll + xml), dependency NumSharp 0.80.0 only
(NumSharp.Build stays a build-time tool), license expression Apache-2.0. (dotnet pack -o <dir> trips NU5026 with the
csproj's GeneratePackageOnBuild=true, kept from 0.4.0; plain dotnet pack / dotnet build -c Release produce the package.)

Solution: 0 warnings, 0 errors; unit 67/67 and oracle 12/12 on net8.0 and net10.0.
…aster than 0.4.0 at every size, and a 400x400x16-state run in 43 MB instead of 4.3 GB (0.42 s instead of 15.2 s)

The baseline profile of 2026-11-08 (one scenario per process, peak working set) put Baum-Welch first: 400 sequences x
400 observations x 16 states x 32 symbols, 3 iterations, took 16.5 s and peaked at 4,257 MB on 88f05c6. Two causes:
the log-space recursions pay an exp and a log1p per summed term (0.4.0's LogHelper.LogSum), and 3226aad's NumSharp
E/M step materialized a (transitions, N, N) ksi tensor plus ~10 tensor-sized temporaries that its NDScope kept alive
until the iteration returned.

The fix is the algorithm, not the vectorization: Rabiner's scaling (BaumWelchLearning.Scaled.cs, RunScaled).
- Forward: alpha_hat_t = (alpha_hat_t-1 A) o B[:, o_t] / c_t with c_t = P(o_t | o_1..o_t-1), accumulated as rows of A
  weighted by alpha_hat_t-1 (contiguous reads). Backward: beta_hat_t[i] = (row i of A) . (B[:, o_t+1] o beta_hat_t+1)
  / c_t+1 (a contiguous dot per state). Plain multiply-adds; no transcendental per term.
- gamma = alpha_hat o beta_hat (sums to 1), log P(sequence) = sum log c_t.
- Transition statistics: sum_t ksi_t = A o sum_t alpha_hat_t (x) w_t with w_t[j] = B[j, o_t+1] beta_hat_t+1[j] / c_t+1 -
  one outer-product accumulation per transition (a SAXPY per state), A applied once after all sequences. No per-step
  (N, N) posterior is stored: memory is the (T x N) buffers of the longest sequence plus O(N^2 + N M) accumulators.
- M-step in probability space, logged into the model's arrays; 0.4.0's rules kept: logA = 0 where numerator ==
  denominator (a never-visited state's 0/0 included), logB = log num - log den, logPi = log sum gamma_0 - log K;
  convergence value log(sum_k P(seq_k)) / K (Accord.NET's, as 3226aad fixed it).
- A sequence impossible under the starting model (a scale of 0) cannot be scaled: Run snapshots the starting
  parameters, and on that signal restores them and runs RunScalar (0.4.0's log-space loops, verbatim but for the
  likelihood fix), reproducing 0.4.0 NaN for NaN. EM never lowers the likelihood, so possible data stays possible.
- Sequence weights: in 0.4.0 (and 3226aad) they are added to every posterior of a time step before its normalization,
  so they cancel; RunScaled therefore ignores them, with the same results (documented in the class remarks).

Removed (superseded): the 25,000 rows x N^2 gate (VectorizedMinimumWork), RunVectorized, Iterate, the Layout of
stacked NDArrays, FromMatrix / CopyInto. BaumWelchLearning no longer uses NumSharp: the recursions are sequential in
time over N-sized vectors, where NumSharp's ~1.5-3 us per call costs more than the arithmetic, and the scaled
statistics need no whole-array step at all.

Measured (benchmarks/BaumWelch.cs, 0.4.0's Run verbatim vs 0.5.0, 10 iterations, warmed + 300 ms pause + warmed,
10 interleaved rounds, best of each): 10x50 N2 16.4 -> 0.28 ms (x58); 20x50 N3 x20; 20x100 N3 x21; 40x100 N3 x24;
20x100 N5 x39; 40x100 N4 x37; 80x100 N3 x25; 20x200 N6 x45; 100x200 N8 2,726 -> 51.9 ms (x53). Against 88f05c6:
x16-58, and 400x400 N16 (3 iterations) 15.2 s -> 0.42 s. Learned log-emissions agree with 88f05c6's to <= 7.2e-14.
Profile (--no-cache build; a cached file-based app keeps the OLD library): 400x400 N16 14.8 s / 4,257 MB -> 0.39 s /
43 MB peak working set (the bare process starts at 41 MB).

Tests: BaumWelchTests' DataRows are named by size now (both run the scaled path) and still match 0.4.0's verbatim
log-space re-estimation at 1e-9; new ImpossibleSequenceFallsBackToTheLogSpaceLoops (a symbol no state emits) equals
0.4.0's result exactly, NaN for NaN. Unit 68/68 on net8.0 and net10.0; the Baum-Welch oracle (NumPy/SciPy log-space)
2/2 at 1e-9. README and benchmarks/README updated.
…ianNB frees each class's temporaries - KNN 8000x8000 3.0 GB -> 170 MB (2x faster), SVM 20000x1960 696 -> 186 MB, GaussianNB 100000x50x10 1.4 GB -> 319 MB (1.7x faster); results unchanged

Root cause found by the baseline profile (2026-11-08, peak working set per scenario process): an NDScope keeps EVERY
temporary its method creates alive until the method returns. A method that loops over classes or pairs, or that
materializes (queries x training samples), therefore peaks at the SUM of its temporaries:
- KnnModel: squared distances = queryNorms - 2 dot + trainNorms, maximum(., 0) - four (queries x training) matrices
  plus argpartition's; 8,000 x 8,000 x 30 peaked at 3,000 MB (the input is 4 MB).
- GaussianNaiveBayes.PredictJointLogProba: per class (X - theta), square, / var - three (samples x features)
  temporaries x 10 classes alive at once; 100,000 x 50 peaked at 1,375 MB (input 40 MB). Fit kept every class's row
  copy until it returned.
- Prediction.DecisionValues: the whole (rows x support vectors) kernel; 20,000 x 1,960 peaked at 695 MB.

Changes
- KnnModel: queries run in blocks of BlockElements = 2^22 distances (32 MB) - BlockRows = max(1, 2^22 / training
  rows) - each block in its own NDScope, results written into preallocated (rows, K) / (rows, classes) outputs by
  slice assignment. SquaredDistances fuses |x|^2 - 2 x.t + |t|^2 and the clamp into ONE np.evaluate pass (one result
  matrix instead of four temporaries; same per-element operations). Votes: one np.bincount over codes shifted into
  per-row slots (row * classes + code), instead of a (block, K, classes) one-hot tensor.
- Prediction.DecisionValues: the dense support vectors (DenseSupportVectors), their RBF norms and the (SV, pairs) pair
  weights (PairWeights) are built once; the kernel (KernelBlock: dot + fused np.evaluate) and the decision values run
  per block of max(1, 2^22 / SV) rows, each in its own scope, into a preallocated result.
- GaussianNaiveBayes: one NDScope per class in Fit and PredictJointLogProba, yielding the class's column / mean / var
  (scope.Returns) - the operations, and so the bits, are unchanged.

Measured (one process per run; time + peak working set)
- KNN predict 8,000 x 8,000 x 30: 1,871 ms / 3,000 MB -> 949 ms / 170 MB; 4,000 x 4,000: 517 ms / 798 MB -> 330 ms /
  211 MB.
- SVM batch predict 20,000 rows x 1,960 SVs, A/B against 010fa28 in a detached worktree, processes alternated 4x:
  before 1,051-1,231 ms / 696 MB, after 984-1,233 ms / 186 MB (same speed; the machine was ~25-50% busy). The
  interleaved per-row benchmark ratio rose to x1.8 / x3.5 / x6.2.
- GaussianNB fit + predict_proba 100,000 x 50, 10 classes, A/B 3x alternated: before 853-1,072 ms / 1,375 MB, after
  509-624 ms / 319 MB.

Tests: new KNNTest.BlocksAgreeWithBruteForce (4,200 training samples -> 998-row blocks, 2,500 queries in three blocks;
rows 0/997/998/1995/1996/2499 have a scalar brute-force search's neighbours, distances to 1e-12 and votes); unit
tests (KNN 6, SVM 15, Bayes 12) pass on net8.0 and net10.0; oracle: KNN vs KNeighborsClassifier, SVM vs SVC, and
GaussianNB still BIT-IDENTICAL to scikit-learn.
…Sharp text array, the tree grown over integer codes - x15 to x70 warm (5,000 rows 191 -> 2.7 ms), a fresh-process Learn of 50,000 rows 1.6 s -> 0.11 s, peak 189 -> 85 MB; trees bit-identical

Root cause (baseline profile 2026-11-08: 5,000 rows x 8 attributes 0.72-0.87 s, 50,000 rows 1.5-1.8 s): every node
re-encoded each of its columns with NumSharp (np.array of the strings, np.unique with return_index/return_inverse, two
argsorts, np.bincount, an entropy of five ufuncs) - a dozen NumSharp calls per attribute however few rows the node
held - and copied its string rows into a reduced TrainingData for every child.

Changes (Bigtree.Algorithm/DecisionTree/Tree.cs)
- CodedTable.Create encodes every column ONCE: np.unique(np.array(column, np.dtype("<U{width}")), return_inverse)
  -> int codes + the code texts. The width (longest value in UTF-16 units, at least 1, never fewer than NumPy's code
  points, so nothing is cut) is measured in C# and passed as the dtype: NumSharp 0.80.0's np.array(string[]) runs
  NumPy's dtype discovery per value (DiscoverDTypeFromPyObject + FindScalarDescriptor + PromoteTypes into the running
  descriptor, UnmanagedStorage.Coercion.Text.cs Discover), measured steady-state at 65-160 ns per string against 7-18 ns
  for the sized request - 10x the cost of the copy; with it, sized array + np.unique costs about what a Dictionary
  encoding does (5,000 strings 0.10 vs 0.06 ms; 50,000 0.9 vs 0.7 ms), so the encoding stays on NumSharp.
- Grow works on row-index subsets of the codes: one pass ranks the labels by first appearance and counts them; one pass
  per attribute ranks its values AND counts the (value x label) table (CountByAppearance); the chosen attribute's table
  says which values are leaves (exactly one nonzero label count; the leaf label is that label) and sizes each impure
  child's row array exactly; one more pass hands every impure value its rows in table order. No string comparisons,
  no reduced-table copies, no LINQ per child.
- Same entropy expression (p * (log(p) / log(2)) summed in first-appearance order, size / rows * H per value), same
  strict-> tie rule (first attribute wins), same edge order, same TableIndex (position among the remaining attributes),
  same majority leaf for contradictory rows.
- New: a null cell raises ArgumentException("Row i has no value in column 'c'.") up front. Before, NumSharp's discovery
  refused it with a NotSupportedException about object arrays; a sized request would store the text "None".

Verification
- Byte-identical trees against the previous implementation (53ab91c) on 336 random tables (1-1,500 rows, 1-7
  attributes, 1-5 labels, duplicated columns for exact gain ties): 4,480 internal nodes and 5,369 leaves, every name,
  TableIndex, edge, value list and InformationGain's raw bits equal (sha256 6C6151D357256542 on both).
- New unit tests: WideAndNonAsciiValuesKeepTheirText (values of 1-25 chars, umlauts, a surrogate pair round-trip as
  edges and leaves), NullCellIsRefused. Unit 71/71 on net8.0 and net10.0 (DecisionTree 7/7); oracle 12/12 on both
  (DecisionTree_GainsMatchSciPyEntropy included).

Measured
- benchmarks/DecisionTree.cs (new; in one process, interleaved, best of 7; baseline = 0.5.0's first port 3226aad
  verbatim, since 0.4.0's ID3 drops the columns left of the split and throws on contradictory rows or exhausted
  columns, which noisy tables always reach; asserts the same tree, gains' bits included):
  1,000 x 6: 27.7 -> 1.9 ms (x15); 5,000 x 8: 190.9 -> 2.7 ms (x70); 50,000 x 8: 576.4 -> 26.3 ms (x22).
- One Learn in a fresh process (time includes JIT; peak working set), A/B against 010fa28 in a detached worktree,
  processes alternated 3x: 5,000 rows 793-874 ms / 88-90 MB -> 41-56 ms / 53 MB; 50,000 rows 1,499-1,811 ms /
  188-189 MB -> 108-112 ms / 85 MB.

Docs: README (model table, performance line, a Memory paragraph on per-class / per-block scopes from 53ab91c, the
decision-tree changes bullet); benchmarks/README (decision-tree section; the SVM table re-measured after 53ab91c's
blocks: x1.6 / x2.8 / x7.0, and its memory note).
…MD pass over the samples' own vectors - 20,000 x 2,000 training pass 0.95-1.95 s / 1,358 MB -> 0.12-0.15 s / 376 MB (320 MB of it the input); x47-x1,147 vs 0.4.0; bits unchanged. NDHelpers.Text sizes text arrays up front

Root cause (baseline profile 2026-11-08: mnb 20000 x 2000 884-1,948 ms, peak 1,357 MB; 5000 x 1000 217-263 ms,
263 MB): CountValue built counts[label, x] = onehot(labels).T @ (X == value) as NumSharp whole-array ops, which first
needed the jagged FeatureSet (List<Tuple<string, double[]>>) as one matrix: a flat double[] copy, an unmanaged NDArray
copy of it, a bool mask and the mask's float64 copy - four (samples x features) temporaries, about 1 GB at
20,000 x 2,000, to produce a 5 x 2,000 table, repeated for every requested value (CherubNLP asks for 0 and 1).

Changes
- MultinomiaNaiveBayes.CountValue: one pass over the samples; per sample, the label's row of a long[labels x features]
  table gets Vector.Equals(Vector<double>(vector, x), target) subtracted (a match is an all-ones lane, -1 as a long)
  a SIMD vector of features at a time, then a scalar tail. IEEE equality as before (-0 counts as 0, NaN matches
  nothing). The counts then go through the SAME NumSharp smoothing + np.log as before on the small table only, so every
  log-probability keeps its bits (and stays bit-identical to 0.4.0's Math.Log formula). checked(labels * features):
  an OverflowException instead of a wrapped size past the largest .NET array.
- NDHelpers.Text(string[]) (new): a 1-D '<U{width}' array with the width (longest value in UTF-16 units, at least 1)
  measured in C# and passed as the dtype - NumSharp 0.80.0's np.array(string[]) runs dtype discovery per value (65-160
  ns per string vs 7-18 ns sized). Null -> ArgumentException (a sized request would store "None"). Used by
  Tree.CodedTable.Create (which still names the row and column of a null cell itself) and by
  MultinomiaNaiveBayes.CountLabels, which now refuses a null label with ArgumentException("FeatureSet[i] has no label.")
  instead of NumSharp's NotSupportedException about object arrays.
- Docs: class / CalCondProb / CountValue remarks describe the pass; CalCondProb documents the OverflowException and the
  null-label ArgumentException.

Verification
- New unit test Multinomial_SignedZeroAndNaNCountLike040: 37 features (whole vectors + a scalar tail at any width),
  values {0, -0, 1, NaN, 2.5}, every label x feature x value bit-identical to the verbatim 0.4.0 reference.
  Multinomial_MatchesBigtree040BitForBit and Multinomial_MultiValuedFeaturesMatchBigtree040 unchanged and green.
- Unit 72/72 on net8.0 and net10.0; ID3 trees still byte-identical on the 336 random tables after the Text refactor
  (sha256 6C6151D357256542).

Measured
- One training pass per process (time; peak working set), A/B against 010fa28 in a detached worktree, processes
  alternated 3x: 5,000 x 1,000: 217-263 ms / 263 MB -> 28-30 ms / 97 MB; 20,000 x 2,000: 945-1,948 ms / 1,358 MB ->
  123-154 ms / 376 MB.
- benchmarks/MultinomialNaiveBayes.cs (0.4.0 verbatim in the same process; 0.5.0 now gets 25 rounds since its rounds
  take milliseconds): 300 x 100 x 4: 33.1 ms vs 0.47 ms (x71); 1,000 x 300 x 5: 1,415 vs 2.37 ms (x598);
  2,000 x 500 x 6: 4,184 vs 4.79 ms (x874); a second run x47 / x574 / x1,147 (0.4.0 moved up to 1.5x with load).
  The 2026-11-08 table had x37 / x216 / x194.
- README / benchmarks/README updated (model table, ratios, the memory note).
…r programs compiled once - 11 NumSharp calls per training step instead of ~24; 50-64-10 x 20,000 rows x 5 epochs: batch 1 7.2-8.2 s -> 2.4-2.6 s, batch 32 510-566 -> 305-328 ms; weights bit-identical

Root cause: a training step on a small batch is dominated by NumSharp's fixed per-call cost, and NetworkModel.Step
spent most of its calls on element-wise chains: the sigmoid (negative, exp, add, divide - four ufuncs, four temporaries,
plus 0-d temporaries for the C# literals), the output delta (subtract, multiply, subtract, multiply), the hidden delta
(three after its product) and the update (rate * gradient, then np.add into the weights).

Changes
- NDHelpers.Sigmoid: a float64 operand goes through SigmoidProgram = (1 / (1 + Exp(Negate(Input(0))))).Compile(Double),
  one fused pass and one result; any other dtype keeps the ufunc chain (its NEP 50 promotion; the compiled program is
  float64-only). Used by NetworkModel's forward pass and PredictProba.
- NetworkModel.Kernels (new, private static): OutputDelta (Input(0) - Input(1)) * Input(1) * (1 - Input(1)),
  HiddenDelta Input(0) * Input(1) * (1 - Input(1)), Update Input(0) + Input(2) * Input(1) - positional float64 programs
  compiled once (CompiledExpression: no per-call tree building or typing, ~0.4 us of per-call work). Step evaluates
  them; the update writes into Weights[l] through out= (the program may alias an input), with the rate as a 0-d float64
  operand (NDArray.Scalar). Same operations in the same order as the unfused formulas.
- Train slices each batch with a Slice object instead of formatting and parsing "start:stop" per batch.
- NetworkModel's "batch size is a speed choice" remark re-measured (it quoted "about 80 times slower than plain loops"
  at batch 1): the 7-5-3 seeds-sized network (210 samples, 50 epochs) against the same algorithm in plain C# loops over
  double[,] weights: batch 1 13.5x slower before -> 6.7x now (231 -> 120 ms vs 17-18 ms); batch 32 2.1x -> 3.9x FASTER
  than the loops (7.97 -> 4.29 ms vs 16.5-16.8 ms).

Verification
- Scratch check of each program against its unfused formula on 4,000 x 64 random float64 (activations x20 so exp over-
  and underflows): sigmoid, output delta, hidden delta and the in-place update all bit-identical.
- Trained weights (every element's bits, SHA-256) after 50-64-10 and 20-16-8-4 networks at batch sizes 1, 7, 32 and 256
  (partial last batches): identical before (010fa28, a detached worktree) and after (9E9A0895AAB7C250 on both).
- Unit 72/72 and oracle 12/12 (NetworkModel vs NumPy's mini-batch backpropagation to 1e-12 included) on net8.0 and
  net10.0.

Measured (one training per process; processes alternated 3x against 010fa28)
- 50-64-10, 20,000 rows, 5 epochs: batch 1 7,180-8,245 ms -> 2,404-2,565 ms (one outlier 5,285); batch 32
  510-566 -> 305-328 ms; batch 256 220-243 -> 201-228 ms; peak working set unchanged (70 MB at 32 / 256).
- What is left at batch 32 is NumSharp's managed GEMM doing real work: the five products take ~55 of a step's ~96 us;
  a transposed operand costs nothing extra (x @ W0.T 15.4 us vs 22.8 us with a C-contiguous copy), so the weights keep
  their documented (out, in) layout.
- README: the model table and a paragraph on fused / compiled element-wise formulas.
…blocks of centered rows, finiteness checks are one fused All(IsFinite) reduction, GaussianNB's distance is one compiled pass - LinearRegression 200,000 x 100 peak 385 -> 277 MB (same speed), GaussianNB distances x3.75 and peak 321 -> 280 MB; GaussianNB still bit-identical to scikit-learn

Root cause (profile 2026-11-09 on b672cbd): LinearRegression.Fit peaked at 385 MB for a 160 MB input - the centered copy
xc = X - mean (160 MB) and Float64Matrix's np.isfinite bool array (20 MB) both lived until the fit's scope closed.
GaussianNaiveBayes.PredictJointLogProba spent most of fit + predict_proba (552-992 ms on a busy machine) in
np.square(X - theta) / var: three (samples x features) passes and temporaries per class.

Changes
- LinearRegressionModel.Fit: gram = sum over row blocks of xc_b.T @ xc_b and moment = sum of xc_b.T @ yc_b, each block
  of BlockElements = 2^22 (32 MB, the KNN / SVM block size) centered in its own NDScope and accumulated with
  np.add(..., @out:); then np.linalg.solve(gram, moment) as before. One block (rows * features <= 2^22, every small
  fit) is the unblocked product bit for bit (0 + x is exact); more blocks group the row sums differently (rounding
  level). yc stays whole (samples x targets, small). Class remarks say so.
- NDHelpers.AllFinite (new): (bool)np.evaluate(NDExpr.All(NDExpr.IsFinite(values))) - one fused reduction, no
  temporary. Float64Matrix (every estimator's X, and 2-D targets) and LinearRegressionModel.Target use it instead of
  np.all(np.isfinite(...)), whose bool array the caller's scope held until the estimator returned.
- GaussianNaiveBayes.WeightedSquare (new): (Square(Input(0) - Input(1)) / Input(2)).Compile(Double, Double, Double),
  evaluated per class over (samples, theta_i, var_i), which broadcast as the ufuncs' operands did; np.sum(axis: 1)
  unchanged. NumPy's ** 2 is its square, so subtract / square / divide are scikit-learn's operations in its order.

Verification
- New unit test FitAcrossSeveralBlocksMatchesTheWholeProduct: 60,000 x 80 (two blocks, 52,428 + 7,572 rows), two
  targets: coefficients equal the whole-matrix normal-equation solve to 1e-12 and the generating weights to 1e-3.
- Scratch check: the compiled distance against np.sum(np.square(X - theta) / var, axis: 1) over 10 classes of
  100,000 x 50: bit-identical.
- Unit 73/73 and oracle 12/12 on net8.0 and net10.0 (GaussianNB bit-identical to GaussianNB: theta_, var_,
  class_prior_, epsilon_ and every prediction output; LinearRegression vs scikit-learn to 1e-10).

Measured
- In process, interleaved, best of 9: the per-class distance over 10 classes x 100,000 x 50, 339.4 ms unfused ->
  90.6 ms compiled (x3.75).
- One run per process, A/B against b672cbd in a detached worktree, alternated 3x (the machine was busy: the same build
  varied up to 1.8x): GaussianNB fit + predict_proba 100,000 x 50, 10 classes 552-992 ms / 321 MB -> 286-541 ms /
  280 MB; LinearRegression fit 200,000 x 100 373-590 ms / 385 MB -> 377-492 ms / 277 MB (same speed).
- README: the fused / compiled paragraph and the Memory paragraph (linear regression blocks; GaussianNB at 280 MB).
…loat64 exp, which NumSharp 0.80.0 computes faster than NumPy 2.4.2 on this AVX2 machine (6.6 vs 7.5 ns per element)

Measured 2026-11-09 to decide whether SVM batch prediction (945 ms for 20,000 rows x 1,960 support vectors) had
NumSharp overhead left to remove. One block of 2,140 rows (in process, best of 9): cross product 13.1 ms, the fused
RBF kernel pass 49.4 ms of which np.exp over its 4.2 M elements 33.8 ms, decision values 18.9 ms (the transposed
formulation (weights.T @ kernel.T).T is the same 18.5 ms).

exp over 4.2 M elements: NumSharp 0.80.0 np.exp float64 27.5 ms (6.55 ns/element), float32 4.7 ms (1.13 ns);
a Math.Exp loop over double[] 31.1 ms (7.42 ns), MathF.Exp over float[] 16.8 ms (4.01 ns); NumPy 2.4.2 float64
31.4 ms (7.49 ns), float32 7.9 ms (1.88 ns). The machine has AVX2 and no AVX-512 (NumPy reports X86_V3 found):
NumPy's float64 exp is vectorized only with AVX-512F, so both libraries run it scalar here. Nothing to remove on
the Bigtree side; the batch path stays as it is.
…are spelled MultinomialNaiveBayes and MultinomialNaiveBayesModel - members, numbers and the JSON model shape unchanged; CherubNLP adopts the two names (three lines) in its net8.0 retarget

Decided by the owner on the PR design pages (2026-11-09, round 2): "Rename outright", over keeping 0.4.0's spelling
(round 1's "keep CherubNLP's surface" otherwise stands) or adding the correct names with the old ones as thin
subclasses. CherubNLP has to change for 0.5.0 anyway (netstandard2.0 -> net8.0, which NumSharp 0.80.0 requires), so the
two names ride on that change: CherubNLP/Classify/NaiveBayesClassifier.cs line 45 (new MultinomiaNaiveBayes()) and
lines 126, 155 (MultinomiaNaiveBayesModel, saved and loaded through JsonConvert).

Changes
- git mv Bigtree.Algorithm/Bayes/MultinomiaNaiveBayes.cs -> MultinomialNaiveBayes.cs and
  MultinomiaNaiveBayesModel.cs -> MultinomialNaiveBayesModel.cs; the class names and the constructor follow.
- Every use: the unit tests (NaiveBayesTests, including the test-local verbatim 0.4.0 copy, now
  LegacyMultinomialNaiveBayes), the oracle test, benchmarks/MultinomialNaiveBayes.cs and the README model table.
- Comments that describe 0.4.0's own class keep its historical spelling (NaiveBayesTests' reference copy, the
  benchmark's Legacy class and header).
- README "Changes from 0.4.0": the rename is listed; CherubNLP compiles once it targets net8.0 and uses the two new
  names; its saved JSON models load unchanged.

Verification
- The model class's members are 0.4.0's exactly (a diff of master's MultinomiaNaiveBayesModel.cs against the renamed
  file differs only in the class name), and Newtonsoft stores property names, not the type name, so CherubNLP's saved
  models deserialize into the renamed class.
- Build 0 warnings / 0 errors; unit 73/73 and oracle 12/12 on net8.0 and net10.0 (Windows 11).
….Open() and no [NDScopedCovered] left in the repository, the analyzer's NDW0xx warnings off in .editorconfig; the tests, the oracle, the console and the benchmark scripts are woven too

The owner's request (2026-11-09): "use [NDScoped] attribute instead of creating scopes. suppress all ND* warnings and
remove [NDScopedCovered] as it adds noise".

Warnings
- .editorconfig [*.cs]: NDW012, NDW013, NDW016, NDW017, NDW020 = none. These are every warning-severity rule NumSharp
  0.80.0's analyzer ships (AnalyzerReleases.Shipped.md; the analyzer comes inside the NumSharp package, so every project
  here had it). Its error rules (NDW002/003/005/006/009/010/011/015) stay: the weaver refuses those shapes at build time
  anyway. The benchmark scripts' #:property NoWarn=NDW012;NDW016;NDW017 lines are gone (BaumWelch.cs keeps CS8632):
  the root .editorconfig reaches file-based apps too.

[NDScopedCovered] removed everywhere (24 in the library, 2 in benchmarks/DecisionTree.cs). It is a marker only: an
NDArray belongs to the innermost scope open on its thread, so a helper's temporaries go to its caller's woven scope
with or without it. NDHelpers' class remarks now say that instead.

Hand-opened scopes -> [NDScoped] (17 library sites, 45 test / console sites, 3 benchmark sites)
- Methods returning NDArray / NDArray[] / void take the attribute directly; scope.Returns(x) becomes return x (the
  woven scope hands a returned array to the caller's): NetworkModel.Train, Prediction.DecisionValues / PredictLabels /
  PredictLabelsProbability, Utils.CrossValFolds.
- Methods the weaver refuses (NDW003: a returned reference type that is not an NDArray carrier) keep their signature
  and delegate to an [NDScoped] void body: Bernoulli / Gaussian / KNN / LinearRegression Fit -> FitCore (Fit still
  returns this); Problem.FromNDArray -> Read (fills a new Problem); Tree.CodedTable.Create -> Encode per column (each
  column's arrays now freed before the next, where the hand scope held all columns to the end);
  MultinomialNaiveBayes.CountValue (returned double[], which NDW003 also refuses: the bug pinned on NumSharp journey5)
  now stores its table into _logCondByValue itself.
- Per-iteration scopes become [NDScoped] methods, same lifetimes: KnnModel.KNeighborsBlock / VotesBlock,
  Prediction.DecisionBlock, LinearRegressionModel.AccumulateBlock, GaussianNaiveBayes.ClassMoments (returns the
  class's (mean, variance, count)) and ClassJointLogLikelihood (returns the class's column).
- Tests (34 unit, 10 oracle) and the console's Main: the scope line at the top of each synchronous void method became
  an [NDScoped] attribute (a script that refused any other shape: not the first statement, not void, async, or
  `scope` used later). Their projects now reference NumSharp.Build 0.80.0 with PrivateAssets="all", like the
  library, so the attributes are woven.
- Benchmarks: SvmBatchPrediction.cs runs each configuration in [NDScoped] Benchmark.Run and each timed batch in
  [NDScoped] PredictBatch; DecisionTree.cs's first-port copy computes its gains in [NDScoped] BestAttribute (returns the
  best index; GetRootNode builds the TreeNode). Both scripts add #:package NumSharp.Build@0.80.0: a probe showed it
  weaves a file-based app (an [NDScoped] method's temporary was disposed on return; no NDW018).

Verification
- Build: 0 warnings, 0 errors (whole solution, both frameworks). Weaver summaries: Bigtree.Algorithm.dll woven 42 =
  the 42 [NDScoped] attributes in the library (24 before; "already-scoped 0"), UnitTest 34, Tests.Oracle 10, Console 1
  = the 45 converted sites.
- Unit 73/73 and oracle 12/12 on net8.0 and net10.0 (GaussianNB still bit-identical to scikit-learn).
- Peak working set of the blocked workloads unchanged (one process each): KNN 8,000 x 8,000 173 MB (was 170), GaussianNB
  fit + predict_proba 100,000 x 50 275 MB (280), LinearRegression 200,000 x 100 278 MB (277), SVM predict 20,000 x
  1,960 SVs 189 MB (186).
- benchmarks/DecisionTree.cs: same tree on all three sizes (x14 / x81 / x22); SvmBatchPrediction.cs: 0 labels
  differing (x1.5 / x3.0 / x7.0).
- README "Memory": scopes are [NDScoped] attributes, the FitCore pattern for NDW003 shapes, the warnings off.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant