diff --git a/.github/workflows/Snapshot.yml b/.github/workflows/Snapshot.yml index 6a7bb54..01ffb95 100644 --- a/.github/workflows/Snapshot.yml +++ b/.github/workflows/Snapshot.yml @@ -10,8 +10,8 @@ jobs: with: dotnet-version: '10.0.x' project-names: 'Numerics' - # Snapshot workflow appends .-dev, so this tracks the next development patch after v2.1.3. - version: '2.1.4' + # Snapshot workflow appends .-dev, so this tracks the next development patch after v2.1.4. + version: '2.1.5' # PR integration already runs the full multi-target test suite. run-tests: false nuget-source: 'https://www.hec.usace.army.mil/nexus/repository/consequences-nuget-public/' diff --git a/CITATION.cff b/CITATION.cff index 7ae7476..6572bd7 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,8 +2,8 @@ cff-version: 1.2.0 message: "If you use this software, please cite our article in the Journal of Open Source Software." type: software title: "Numerics: A .NET Library for Numerical Computing, Statistical Analysis, and Risk Assessment" -version: "2.1.3" -date-released: "2026-07-15" +version: "2.1.4" +date-released: "2026-07-17" license: 0BSD repository-code: "https://github.com/USACE-RMC/Numerics" url: "https://github.com/USACE-RMC/Numerics" diff --git a/Numerics/Data/Statistics/BoxCox.cs b/Numerics/Data/Statistics/BoxCox.cs index 1d3d406..519294e 100644 --- a/Numerics/Data/Statistics/BoxCox.cs +++ b/Numerics/Data/Statistics/BoxCox.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Numerics.Mathematics.Optimization; @@ -28,17 +28,35 @@ public class BoxCox /// public static void FitLambda(IList values, out double lambda) { - // Solve with Brent - var brent = new BrentSearch((x) => { return LogLikelihood(values, x); }, -5d, 5d); - brent.Maximize(); - if (brent.Status == OptimizationStatus.Success) + lambda = double.NaN; + if (!CanFitLambda(values)) + return; + + // Keep BrentSearch arithmetic finite even when the profile likelihood is undefined. + var brent = new BrentSearch((x) => { - lambda = brent.BestParameterSet.Values[0]; - } - else + double logLikelihood = LogLikelihood(values, x); + return Tools.IsFinite(logLikelihood) ? logLikelihood : -double.MaxValue; + }, -5d, 5d) { - lambda = double.NaN; - } + ReportFailure = false, + ComputeHessian = false, + RecordTraces = false + }; + + brent.Maximize(); + if (brent.Status != OptimizationStatus.Success || + brent.BestParameterSet.Values == null || + brent.BestParameterSet.Values.Length == 0) + return; + + double candidate = brent.BestParameterSet.Values[0]; + if (!Tools.IsFinite(candidate) || + Math.Abs(candidate) > 5d || + !Tools.IsFinite(LogLikelihood(values, candidate))) + return; + + lambda = candidate; } /// @@ -52,6 +70,9 @@ public static void FitLambda(IList values, out double lambda) /// public static double LogLikelihood(IList values, double lambda1) { + if (!CanFitLambda(values) || !Tools.IsFinite(lambda1) || Math.Abs(lambda1) > 5d) + return double.NegativeInfinity; + int n = values.Count; var y = new double[n]; double mu = 0d; @@ -59,16 +80,28 @@ public static double LogLikelihood(IList values, double lambda1) for (int i = 0; i < n; i++) { y[i] = Transform(values[i], lambda1); + if (!Tools.IsFinite(y[i])) + return double.NegativeInfinity; + mu += y[i]; sumX += Math.Log(values[i]); } + if (!Tools.IsFinite(mu) || !Tools.IsFinite(sumX)) + return double.NegativeInfinity; + mu = mu / n; double sse = 0d; for (int i = 0; i < n; i++) sse += Math.Pow(y[i] - mu, 2d); + if (!Tools.IsFinite(sse) || sse <= 0d) + return double.NegativeInfinity; + double sigma = Math.Sqrt(sse / n); + if (!Tools.IsFinite(sigma) || sigma <= 0d) + return double.NegativeInfinity; + double ll = -n / 2.0d * Tools.LogSqrt2PI - n / 2.0d * Math.Log(sigma * sigma) - 1.0d / (2d * sigma * sigma) * sse + (lambda1 - 1d) * sumX; - return ll; + return Tools.IsFinite(ll) ? ll : double.NegativeInfinity; } /// @@ -146,5 +179,32 @@ public static List InverseTransform(IList values, double lambda) newValues.Add(InverseTransform(values[i], lambda)); return newValues; } + + /// + /// Determines whether a sample can support Box-Cox lambda fitting. + /// + /// The sample values to inspect. + /// when the sample is finite, positive, and non-degenerate. + private static bool CanFitLambda(IList values) + { + if (values == null || values.Count < 2) + return false; + + double first = values[0]; + if (!Tools.IsFinite(first) || first <= 0d) + return false; + + bool hasDifferentValue = false; + for (int i = 1; i < values.Count; i++) + { + double value = values[i]; + if (!Tools.IsFinite(value) || value <= 0d) + return false; + if (value != first) + hasDifferentValue = true; + } + + return hasDifferentValue; + } } } \ No newline at end of file diff --git a/Numerics/Data/Statistics/YeoJohnson.cs b/Numerics/Data/Statistics/YeoJohnson.cs index b85adf1..0dff5d8 100644 --- a/Numerics/Data/Statistics/YeoJohnson.cs +++ b/Numerics/Data/Statistics/YeoJohnson.cs @@ -1,4 +1,4 @@ -using Numerics.Mathematics.Optimization; +using Numerics.Mathematics.Optimization; using System; using System.Collections.Generic; @@ -24,17 +24,35 @@ public class YeoJohnson /// Output. The transformation exponent. Range -5 to +5. public static void FitLambda(IList values, out double lambda) { - // Solve with Brent - var brent = new BrentSearch((x) => { return LogLikelihood(values, x); }, -5d, 5d); - brent.Maximize(); - if (brent.Status == OptimizationStatus.Success) + lambda = double.NaN; + if (!CanFitLambda(values)) + return; + + // Keep BrentSearch arithmetic finite even when the profile likelihood is undefined. + var brent = new BrentSearch((x) => { - lambda = brent.BestParameterSet.Values[0]; - } - else + double logLikelihood = LogLikelihood(values, x); + return Tools.IsFinite(logLikelihood) ? logLikelihood : -double.MaxValue; + }, -5d, 5d) { - lambda = double.NaN; - } + ReportFailure = false, + ComputeHessian = false, + RecordTraces = false + }; + + brent.Maximize(); + if (brent.Status != OptimizationStatus.Success || + brent.BestParameterSet.Values == null || + brent.BestParameterSet.Values.Length == 0) + return; + + double candidate = brent.BestParameterSet.Values[0]; + if (!Tools.IsFinite(candidate) || + Math.Abs(candidate) > 5d || + !Tools.IsFinite(LogLikelihood(values, candidate))) + return; + + lambda = candidate; } /// @@ -62,6 +80,9 @@ public static double FitLambda(IList values) /// public static double LogLikelihood(IList values, double lambda) { + if (!CanFitLambda(values) || !Tools.IsFinite(lambda) || Math.Abs(lambda) > 5d) + return double.NegativeInfinity; + int n = values.Count; var transformed = new double[n]; double sum = 0d; @@ -72,8 +93,13 @@ public static double LogLikelihood(IList values, double lambda) { double xi = values[i]; double yi = Transform(xi, lambda); + if (!Tools.IsFinite(yi)) + return double.NegativeInfinity; + transformed[i] = yi; sum += yi; + if (!Tools.IsFinite(sum)) + return double.NegativeInfinity; // Compute derivative dT/dy for log-Jacobian double dTdy; @@ -91,6 +117,8 @@ public static double LogLikelihood(IList values, double lambda) logJacobianSum += Math.Log(dTdy); else return double.NegativeInfinity; // log-likelihood undefined + if (!Tools.IsFinite(logJacobianSum)) + return double.NegativeInfinity; } // Compute mean and SSE @@ -101,14 +129,16 @@ public static double LogLikelihood(IList values, double lambda) double resid = transformed[i] - mu; sse += resid * resid; } + if (!Tools.IsFinite(sse) || sse <= 0d) + return double.NegativeInfinity; double sigmaSq = sse / n; - if (sigmaSq <= 0 || double.IsNaN(sigmaSq)) + if (!Tools.IsFinite(sigmaSq) || sigmaSq <= 0) return double.NegativeInfinity; double logLikelihood = -n / 2.0 * Tools.LogSqrt2PI - n / 2.0 * Math.Log(sigmaSq) - sse / (2.0 * sigmaSq) + logJacobianSum; - return logLikelihood; + return Tools.IsFinite(logLikelihood) ? logLikelihood : double.NegativeInfinity; } /// @@ -160,11 +190,11 @@ public static double Transform(double value, double lambda) { return Math.Log(value + 1); } - else if (value < 0 && lambda != 2) + else if (value < 0 && Math.Abs(lambda - 2d) >= 1E-8) { return -(Math.Pow(-value + 1, 2 - lambda) - 1) / (2 - lambda); } - else if (value < 0 && lambda == 2) + else if (value < 0 && Math.Abs(lambda - 2d) < 1E-8) { return -Math.Log(-value + 1); } @@ -216,11 +246,11 @@ public static double InverseTransform(double value, double lambda) { return Math.Exp(value) - 1; } - else if (value < 0 && lambda != 2) + else if (value < 0 && Math.Abs(lambda - 2d) >= 1E-8) { return 1 - (Math.Pow(1 - (2 - lambda) * value, 1 / (2 - lambda))); } - else if (value < 0 && lambda == 2) + else if (value < 0 && Math.Abs(lambda - 2d) < 1E-8) { return 1 - Math.Exp(-value); } @@ -242,5 +272,32 @@ public static List InverseTransform(IList values, double lambda) newValues.Add(InverseTransform(values[i], lambda)); return newValues; } + + /// + /// Determines whether a sample can support Yeo-Johnson lambda fitting. + /// + /// The sample values to inspect. + /// when the sample is finite and non-degenerate. + private static bool CanFitLambda(IList values) + { + if (values == null || values.Count < 2) + return false; + + double first = values[0]; + if (!Tools.IsFinite(first)) + return false; + + bool hasDifferentValue = false; + for (int i = 1; i < values.Count; i++) + { + double value = values[i]; + if (!Tools.IsFinite(value)) + return false; + if (value != first) + hasDifferentValue = true; + } + + return hasDifferentValue; + } } } diff --git a/Numerics/Distributions/Univariate/Base/UnivariateDistributionFactory.cs b/Numerics/Distributions/Univariate/Base/UnivariateDistributionFactory.cs index b9179f8..e1c6685 100644 --- a/Numerics/Distributions/Univariate/Base/UnivariateDistributionFactory.cs +++ b/Numerics/Distributions/Univariate/Base/UnivariateDistributionFactory.cs @@ -124,6 +124,38 @@ public static UnivariateDistributionBase CreateDistribution(UnivariateDistributi } } + /// + /// Attempts to create a distribution that can be initialized without external component distributions. + /// + /// Distribution type. + /// + /// When this method returns , contains a distribution whose + /// matches ; + /// otherwise, . + /// + /// + /// when the requested distribution can be created without external components; + /// otherwise, . + /// + /// + /// Composite distributions such as and , + /// user-defined distributions, and undefined enumeration values cannot be created by this method. + /// + public static bool TryCreateDistribution(UnivariateDistributionType distributionType, out UnivariateDistributionBase? distribution) + { + if (!Enum.IsDefined(typeof(UnivariateDistributionType), distributionType) || + distributionType == UnivariateDistributionType.CompetingRisks || + distributionType == UnivariateDistributionType.Mixture || + distributionType == UnivariateDistributionType.UserDefined) + { + distribution = null; + return false; + } + + distribution = CreateDistribution(distributionType); + return true; + } + /// /// Create a distribution from XElement. /// diff --git a/Numerics/Distributions/Univariate/EmpiricalDistribution.cs b/Numerics/Distributions/Univariate/EmpiricalDistribution.cs index 4dc5f2b..a29353e 100644 --- a/Numerics/Distributions/Univariate/EmpiricalDistribution.cs +++ b/Numerics/Distributions/Univariate/EmpiricalDistribution.cs @@ -66,8 +66,11 @@ public EmpiricalDistribution(IList XValues, IList PValues) /// /// Array of X values. /// Array of probability values. Range 0 ≤ p ≤ 1. - /// Sort order of X values. + /// Ascending sort order of X values. /// Sort order of probability values. + /// + /// Thrown when the distribution is used and is not ascending. + /// public EmpiricalDistribution(IList XValues, IList PValues, SortOrder XOrder, SortOrder probabilityOrder) { SetParameters(XValues, PValues, XOrder, probabilityOrder); @@ -78,9 +81,12 @@ public EmpiricalDistribution(IList XValues, IList PValues, SortO /// Constructs a Univariate Empirical CDF from ordered paired data. /// /// The ordered paired data. + /// + /// Thrown when the X values are not configured in ascending order. + /// public EmpiricalDistribution(OrderedPairedData orderedPairedData) { - if (orderedPairedData.OrderX != SortOrder.Ascending) throw new ArgumentException("The x values must be in ascending order", nameof(orderedPairedData)); + if (orderedPairedData.OrderX != SortOrder.Ascending) throw new ArgumentOutOfRangeException(nameof(orderedPairedData), "Empirical x-values must be ordered ascending."); opd = orderedPairedData; _xValues = orderedPairedData.Select(v => v.X).ToArray(); @@ -366,9 +372,12 @@ public void SetParameters(IList xValues, IList pValues) /// /// Array of X values. /// Array of probability values. Range 0 ≤ p ≤ 1. - /// Sort order of X values. + /// Ascending sort order of X values. /// Sort order of probability values. /// The value and probability collections have different lengths. + /// + /// Thrown when the distribution is used and is not ascending. + /// public void SetParameters(IList xValues, IList pValues, SortOrder XOrder, SortOrder probabilityOrder) { if (xValues.Count != pValues.Count) @@ -397,20 +406,29 @@ public void SetParameters(IList xValues, IList pValues, SortOrde { exception = new ArgumentOutOfRangeException(nameof(data), "At least two empirical ordinates are required."); } + else if (probabilities.Count != data.Count) + { + exception = new ArgumentOutOfRangeException(nameof(probabilities), "The empirical ordinate and probability collections must have the same length."); + } + else if (data.OrderX != SortOrder.Ascending) + { + exception = new ArgumentOutOfRangeException(nameof(data), "Empirical x-values must be ordered ascending."); + } else if (!data.IsValid) { - exception = new ArgumentOutOfRangeException(nameof(data), "Empirical ordinates must be finite and follow the configured ordering."); + List errors = data.GetErrors().Distinct().ToList(); + string message = errors.Count > 0 + ? string.Join(" ", errors) + : "The empirical ordinates do not satisfy their configured ordering."; + exception = new ArgumentOutOfRangeException(nameof(data), message); } - else + else if (probabilities.Any(probability => + double.IsNaN(probability) || + double.IsInfinity(probability) || + probability < 0d || + probability > 1d)) { - for (int i = 0; i < probabilities.Count; i++) - { - if (double.IsNaN(probabilities[i]) || double.IsInfinity(probabilities[i]) || probabilities[i] < 0d || probabilities[i] > 1d) - { - exception = new ArgumentOutOfRangeException(nameof(probabilities), "Empirical probabilities must be finite and between zero and one."); - break; - } - } + exception = new ArgumentOutOfRangeException(nameof(probabilities), "Empirical probabilities must be finite and between zero and one."); } if (throwException && exception is not null) throw exception; return exception; diff --git a/Numerics/Distributions/Univariate/LogPearsonTypeIII.cs b/Numerics/Distributions/Univariate/LogPearsonTypeIII.cs index 4af02d1..5a15e28 100644 --- a/Numerics/Distributions/Univariate/LogPearsonTypeIII.cs +++ b/Numerics/Distributions/Univariate/LogPearsonTypeIII.cs @@ -687,7 +687,7 @@ public Tuple GetParameterConstraints(IList var lowerVals = new double[NumberOfParameters]; var upperVals = new double[NumberOfParameters]; // - // Estimate initial values using the method of linear moments. + // Estimate initial values using the method of moments. var mom = IndirectMethodOfMoments(sample); initialVals = [mom[0], mom[1], mom[2]]; // Get bounds of mean diff --git a/Numerics/Distributions/Univariate/Mixture.cs b/Numerics/Distributions/Univariate/Mixture.cs index 763d022..070ced2 100644 --- a/Numerics/Distributions/Univariate/Mixture.cs +++ b/Numerics/Distributions/Univariate/Mixture.cs @@ -71,12 +71,20 @@ public Mixture(double[] weights, IUnivariateDistribution[] distributions) /// /// Gets or sets whether a separate probability weight is assigned to values less than or equal to zero. /// + /// + /// Enabling zero inflation rescales finite, nonnegative component weights so their sum + /// equals 1 - . + /// public bool IsZeroInflated { get { return _isZeroInflated; } set { _isZeroInflated = value; + if (_isZeroInflated) + { + NormalizeComponentWeights(); + } RefreshConfigurationState(); } } @@ -84,16 +92,62 @@ public bool IsZeroInflated /// /// Gets or sets the zero-value probability weight used when the mixture is zero-inflated. /// + /// + /// When zero inflation is enabled, assigning this property rescales finite, nonnegative + /// component weights to the remaining probability mass. + /// public double ZeroWeight { get { return _zeroWeight; } set { _zeroWeight = value; + if (IsZeroInflated) + { + NormalizeComponentWeights(); + } RefreshConfigurationState(); } } + /// + /// Rescales valid component weights to the probability mass remaining after zero inflation. + /// + /// + /// Invalid zero weights and invalid component weights are left unchanged so parameter + /// validation can report the original configuration error. + /// + private void NormalizeComponentWeights() + { + if (_weights is null || _weights.Length == 0 || + double.IsNaN(ZeroWeight) || double.IsInfinity(ZeroWeight) || + ZeroWeight < 0.0 || ZeroWeight > 1.0) + { + return; + } + + double sum = 0.0; + for (int i = 0; i < _weights.Length; i++) + { + if (double.IsNaN(_weights[i]) || double.IsInfinity(_weights[i]) || _weights[i] < 0.0) + { + return; + } + sum += _weights[i]; + } + + if (sum <= 0.0 || double.IsInfinity(sum)) + { + return; + } + + double scale = (1.0 - ZeroWeight) / sum; + for (int i = 0; i < _weights.Length; i++) + { + _weights[i] *= scale; + } + } + /// /// Refreshes validity and cached results after zero-inflation configuration changes. /// diff --git a/Numerics/Functions/Link Functions/YeoJohnsonLink.cs b/Numerics/Functions/Link Functions/YeoJohnsonLink.cs index 61b9bde..941b65e 100644 --- a/Numerics/Functions/Link Functions/YeoJohnsonLink.cs +++ b/Numerics/Functions/Link Functions/YeoJohnsonLink.cs @@ -63,7 +63,11 @@ public YeoJohnsonLink(double[] values) if (!Tools.IsFinite(values[i])) throw new ArgumentException("Every representative value must be finite.", nameof(values)); - Lambda = ValidateLambda(YeoJohnson.FitLambda(values), nameof(values)); + double lambda = YeoJohnson.FitLambda(values); + if (!Tools.IsFinite(lambda)) + throw new ArgumentException("Yeo-Johnson lambda fitting failed for the supplied representative values.", nameof(values)); + + Lambda = ValidateLambda(lambda, nameof(values)); } /// diff --git a/Numerics/Numerics.csproj b/Numerics/Numerics.csproj index 32852b4..af13488 100644 --- a/Numerics/Numerics.csproj +++ b/Numerics/Numerics.csproj @@ -28,9 +28,9 @@ - 2.1.3 - Version 2.1.3 hardens numerical edge cases and distribution validation. This release enforces XML documentation warnings, fixes audited interpolation/search, histogram, probability, MVNDST/COVSRT, Brent bracketing, and atomic double-addition behavior, improves copula clone and parameter-validity handling, and adds MultivariateNormal non-throwing covariance mutation plus marginal and conditional utilities with expanded regression coverage. - 2.1.3.0 + 2.1.4 + Version 2.1.4 adds non-throwing univariate distribution factory creation, preserves valid zero-inflated mixture weights, hardens Box-Cox and Yeo-Johnson fitting and inversion against invalid candidates, and enforces ascending empirical distribution ordinates with expanded regression coverage. + 2.1.4.0 diff --git a/Test_Numerics/Data/Statistics/Test_BoxCox.cs b/Test_Numerics/Data/Statistics/Test_BoxCox.cs index a9205d4..59d071a 100644 --- a/Test_Numerics/Data/Statistics/Test_BoxCox.cs +++ b/Test_Numerics/Data/Statistics/Test_BoxCox.cs @@ -1,5 +1,6 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using Numerics.Data.Statistics; +using System.Collections.Generic; namespace Data.Statistics { @@ -51,6 +52,42 @@ public void Test_Fit_R() Assert.AreEqual(0.1314482, l1, 1E-6); } + /// + /// FitLambda reports invalid or degenerate samples with NaN instead of throwing through BrentSearch. + /// + [TestMethod] + public void Test_Fit_InvalidSamples_ReturnsNaN() + { + IList[] samples = + { + null!, + new[] { 1d }, + new[] { 1d, 1d, 1d }, + new[] { 0d, 10d, 11d }, + new[] { -1d, 10d, 11d }, + new[] { 1d, double.NaN, 2d }, + new[] { 1d, double.PositiveInfinity, 2d } + }; + + foreach (IList sample in samples) + { + BoxCox.FitLambda(sample, out double lambda); + Assert.IsTrue(double.IsNaN(lambda)); + } + } + + /// + /// LogLikelihood returns negative infinity for samples outside the Box-Cox domain. + /// + [TestMethod] + public void Test_LogLikelihood_InvalidSamples_ReturnsNegativeInfinity() + { + Assert.AreEqual(double.NegativeInfinity, BoxCox.LogLikelihood(new[] { 0d, 1d }, 1d)); + Assert.AreEqual(double.NegativeInfinity, BoxCox.LogLikelihood(new[] { 1d, 1d }, 1d)); + Assert.AreEqual(double.NegativeInfinity, BoxCox.LogLikelihood(new[] { 1d, double.NaN }, 1d)); + Assert.AreEqual(double.NegativeInfinity, BoxCox.LogLikelihood(new[] { 1d, 2d }, double.NaN)); + } + /// /// Test the transform and reverse transform methods /// diff --git a/Test_Numerics/Data/Statistics/Test_YeoJohnson.cs b/Test_Numerics/Data/Statistics/Test_YeoJohnson.cs new file mode 100644 index 0000000..8512570 --- /dev/null +++ b/Test_Numerics/Data/Statistics/Test_YeoJohnson.cs @@ -0,0 +1,65 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Numerics; +using Numerics.Data.Statistics; +using System.Collections.Generic; + +namespace Data.Statistics +{ + /// + /// Unit tests for Yeo-Johnson transformation fitting and likelihood behavior. + /// + [TestClass] + public class Test_YeoJohnson + { + /// + /// FitLambda returns a finite lambda for a non-degenerate finite sample. + /// + [TestMethod] + public void Test_FitLambda_ValidSample_ReturnsFiniteLambda() + { + var sample = new[] { -2d, -1d, -0.25d, 0d, 0.5d, 1d, 3d }; + + YeoJohnson.FitLambda(sample, out double lambda); + + Assert.IsTrue(Tools.IsFinite(lambda)); + Assert.IsTrue(lambda >= -5d && lambda <= 5d); + } + + /// + /// FitLambda reports invalid or degenerate samples with NaN instead of throwing through BrentSearch. + /// + [TestMethod] + public void Test_FitLambda_InvalidSamples_ReturnsNaN() + { + IList[] samples = + { + null!, + new[] { 1d }, + new[] { 1d, 1d, 1d }, + new[] { 1d, double.NaN, 2d }, + new[] { 1d, double.PositiveInfinity, 2d }, + new[] { -double.MaxValue, -double.MaxValue / 2d, -double.MaxValue / 4d } + }; + + foreach (IList sample in samples) + { + YeoJohnson.FitLambda(sample, out double lambda); + Assert.IsTrue(double.IsNaN(lambda)); + } + } + + /// + /// LogLikelihood returns negative infinity for unsupported samples or lambda values. + /// + [TestMethod] + public void Test_LogLikelihood_InvalidSamples_ReturnsNegativeInfinity() + { + Assert.AreEqual(double.NegativeInfinity, YeoJohnson.LogLikelihood(new[] { 1d, 1d }, 1d)); + Assert.AreEqual(double.NegativeInfinity, YeoJohnson.LogLikelihood(new[] { 1d, double.NaN }, 1d)); + Assert.AreEqual(double.NegativeInfinity, YeoJohnson.LogLikelihood(new[] { 1d, 2d }, double.NaN)); + Assert.AreEqual( + double.NegativeInfinity, + YeoJohnson.LogLikelihood(new[] { -double.MaxValue, -double.MaxValue / 2d, -double.MaxValue / 4d }, 1d)); + } + } +} diff --git a/Test_Numerics/Distributions/Univariate/Test_EmpiricalDistribution.cs b/Test_Numerics/Distributions/Univariate/Test_EmpiricalDistribution.cs index e754eb4..01a2727 100644 --- a/Test_Numerics/Distributions/Univariate/Test_EmpiricalDistribution.cs +++ b/Test_Numerics/Distributions/Univariate/Test_EmpiricalDistribution.cs @@ -326,5 +326,140 @@ public void Test_NonPowerOfTwoPoints() } } + /// + /// Duplicate sample values are valid empirical ordinates for bootstrap samples. + /// + [TestMethod] + public void Test_DuplicateSampleValues_AreValidForCdfAndInverseCdf() + { + var sample = new[] { 100d, 100d, 125d, 150d, 150d, 200d }; + var distribution = new EmpiricalDistribution(sample); + + double cdf = distribution.CDF(150d); + double quantile = distribution.InverseCDF(0.5d); + + Assert.IsFalse(double.IsNaN(cdf) || double.IsInfinity(cdf)); + Assert.IsTrue(cdf >= 0d && cdf <= 1d); + Assert.IsFalse(double.IsNaN(quantile) || double.IsInfinity(quantile)); + Assert.IsTrue(quantile >= distribution.Minimum && quantile <= distribution.Maximum); + } + + /// + /// Duplicate x-values are valid when the empirical ordinate set is explicitly non-strict on x. + /// + [TestMethod] + public void Test_NonStrictDuplicateXValues_AreValidForCdfAndInverseCdf() + { + var xValues = new[] { 100d, 100d, 125d, 150d, 200d }; + var pValues = new[] { 0.1d, 0.2d, 0.45d, 0.7d, 0.95d }; + var distribution = new EmpiricalDistribution(xValues, pValues); + + double cdf = distribution.CDF(125d); + double quantile = distribution.InverseCDF(0.45d); + + Assert.IsFalse(double.IsNaN(cdf) || double.IsInfinity(cdf)); + Assert.IsTrue(cdf >= 0d && cdf <= 1d); + Assert.AreEqual(125d, quantile, 1E-12); + } + + /// + /// Duplicate x-values are rejected when the empirical ordinate set requires strict x ordering. + /// + [TestMethod] + public void Test_StrictDuplicateXValues_ThrowWhenUsed() + { + var orderedPairedData = new OrderedPairedData( + new[] { 100d, 100d, 125d }, + new[] { 0.1d, 0.2d, 0.9d }, + true, + SortOrder.Ascending, + true, + SortOrder.Ascending); + var distribution = new EmpiricalDistribution(orderedPairedData); + + Assert.Throws(() => distribution.CDF(110d)); + } + + /// + /// Decreasing x-values remain invalid for empirical distributions configured as ascending. + /// + [TestMethod] + public void Test_DecreasingXValues_ThrowWhenUsed() + { + var distribution = new EmpiricalDistribution( + new[] { 100d, 150d, 125d }, + new[] { 0.1d, 0.5d, 0.9d }); + + Assert.Throws(() => distribution.CDF(125d)); + } + + /// + /// Empirical X values require an explicit ascending order even when the values happen to be ordered. + /// + [TestMethod] + public void Test_NoneXOrder_ThrowsForCdfAndInverseCdf() + { + var distribution = new EmpiricalDistribution( + new[] { 100d, 125d, 150d }, + new[] { 0.1d, 0.5d, 0.9d }, + SortOrder.None, + SortOrder.Ascending); + + Assert.Throws(() => distribution.CDF(125d)); + Assert.Throws(() => distribution.InverseCDF(0.5d)); + } + + /// + /// Descending empirical X values are rejected because empirical interpolation requires ascending X values. + /// + [TestMethod] + public void Test_DescendingXOrder_ThrowsForCdfAndInverseCdf() + { + var distribution = new EmpiricalDistribution( + new[] { 200d, 150d, 100d }, + new[] { 0.9d, 0.5d, 0.1d }, + SortOrder.Descending, + SortOrder.Descending); + var orderedPairedData = new OrderedPairedData( + new[] { 200d, 150d, 100d }, + new[] { 0.9d, 0.5d, 0.1d }, + true, + SortOrder.Descending, + true, + SortOrder.Descending); + + Assert.Throws(() => distribution.CDF(150d)); + Assert.Throws(() => distribution.InverseCDF(0.5d)); + Assert.Throws(() => new EmpiricalDistribution(orderedPairedData)); + } + + /// + /// Descending probabilities remain valid when ascending X values define a survival curve. + /// + [TestMethod] + public void Test_DescendingProbabilityOrder_SupportsCdfAndInverseCdf() + { + var distribution = new EmpiricalDistribution( + new[] { 100d, 150d, 200d }, + new[] { 0.9d, 0.5d, 0.1d }, + SortOrder.Ascending, + SortOrder.Descending); + + Assert.AreEqual(0.5d, distribution.CDF(150d), 1E-12); + Assert.AreEqual(150d, distribution.InverseCDF(0.5d), 1E-12); + } + + /// + /// Non-finite probabilities remain invalid. + /// + [TestMethod] + public void Test_NonFiniteProbability_ThrowsWhenUsed() + { + var distribution = new EmpiricalDistribution( + new[] { 100d, 125d, 150d }, + new[] { 0.1d, double.NaN, 0.9d }); + + Assert.Throws(() => distribution.CDF(125d)); + } } } diff --git a/Test_Numerics/Distributions/Univariate/Test_Mixture.cs b/Test_Numerics/Distributions/Univariate/Test_Mixture.cs index ed94ae0..8f0208e 100644 --- a/Test_Numerics/Distributions/Univariate/Test_Mixture.cs +++ b/Test_Numerics/Distributions/Univariate/Test_Mixture.cs @@ -249,6 +249,56 @@ public void Test_Mixture_InverseCDF_ZeroInflated() } } + /// + /// Test that enabling zero inflation rescales component weights to the remaining mass. + /// + [TestMethod] + public void Test_Mixture_ZeroInflation_RescalesComponentWeights() + { + var mix = new Mixture( + new[] { 0.25, 0.75 }, + new UnivariateDistributionBase[] { new Normal(0, 1), new Normal(5, 2) }); + + mix.ZeroWeight = 0.2; + mix.IsZeroInflated = true; + + Assert.IsTrue(mix.ParametersValid); + Assert.AreEqual(0.2, mix.Weights[0], 1E-12); + Assert.AreEqual(0.6, mix.Weights[1], 1E-12); + Assert.AreEqual(1.0, mix.ZeroWeight + mix.Weights[0] + mix.Weights[1], 1E-12); + + mix.IsZeroInflated = false; + + Assert.IsFalse(mix.ParametersValid); + } + + /// + /// Test that cloning preserves a valid zero-inflated mixture and its evaluated values. + /// + [TestMethod] + public void Test_Mixture_Clone_PreservesZeroInflation() + { + var mix = new Mixture( + new[] { 1.0 }, + new UnivariateDistributionBase[] { new Normal(100, 10) }) + { + IsZeroInflated = true, + ZeroWeight = 0.5 + }; + + var clone = (Mixture)mix.Clone(); + + Assert.IsTrue(mix.ParametersValid); + Assert.IsTrue(clone.ParametersValid); + Assert.IsTrue(clone.IsZeroInflated); + Assert.AreEqual(0.5, mix.Weights[0], 1E-12); + Assert.AreEqual(mix.ZeroWeight, clone.ZeroWeight, 0.0); + Assert.AreEqual(mix.Weights[0], clone.Weights[0], 0.0); + Assert.AreEqual(mix.PDF(100.0), clone.PDF(100.0), 1E-12); + Assert.AreEqual(mix.CDF(100.0), clone.CDF(100.0), 1E-12); + Assert.AreNotSame(mix.Distributions[0], clone.Distributions[0]); + } + /// /// Test that a mixture with no component support at x returns log(0). /// diff --git a/Test_Numerics/Distributions/Univariate/Test_UnivariateDistributionFactory.cs b/Test_Numerics/Distributions/Univariate/Test_UnivariateDistributionFactory.cs index 7bffc26..0fab208 100644 --- a/Test_Numerics/Distributions/Univariate/Test_UnivariateDistributionFactory.cs +++ b/Test_Numerics/Distributions/Univariate/Test_UnivariateDistributionFactory.cs @@ -24,11 +24,18 @@ public void EveryDefinedDistributionTypeIsHandledExplicitly() { AssertThrows( () => UnivariateDistributionFactory.CreateDistribution(type)); + Assert.IsFalse( + UnivariateDistributionFactory.TryCreateDistribution(type, out var unsupportedDistribution)); + Assert.IsNull(unsupportedDistribution); } else { var distribution = UnivariateDistributionFactory.CreateDistribution(type); Assert.AreEqual(type, distribution.Type); + Assert.IsTrue( + UnivariateDistributionFactory.TryCreateDistribution(type, out var createdDistribution)); + Assert.IsNotNull(createdDistribution); + Assert.AreEqual(type, createdDistribution.Type); } } } @@ -41,6 +48,9 @@ public void UndefinedDistributionTypeThrowsArgumentOutOfRange() { AssertThrows( () => UnivariateDistributionFactory.CreateDistribution((UnivariateDistributionType)int.MaxValue)); + Assert.IsFalse(UnivariateDistributionFactory.TryCreateDistribution( + (UnivariateDistributionType)int.MaxValue, out var distribution)); + Assert.IsNull(distribution); } /// diff --git a/Test_Numerics/Functions/Test_YeoJohnsonLink.cs b/Test_Numerics/Functions/Test_YeoJohnsonLink.cs index dc84ad7..0f286b1 100644 --- a/Test_Numerics/Functions/Test_YeoJohnsonLink.cs +++ b/Test_Numerics/Functions/Test_YeoJohnsonLink.cs @@ -89,6 +89,19 @@ public void Constructor_Values_ProducesFiniteLambda() Assert.IsTrue(Tools.IsFinite(link.Lambda)); } + /// + /// Verifies failed lambda fitting from finite values throws a controlled argument exception. + /// + [TestMethod] + public void Constructor_Values_LambdaFitFailure_Throws() + { + var exception = Assert.Throws(() => + new YeoJohnsonLink(new[] { -double.MaxValue, -double.MaxValue / 2d, -double.MaxValue / 4d })); + + Assert.AreEqual("values", exception.ParamName); + StringAssert.Contains(exception.Message, "lambda fitting failed"); + } + /// /// Verifies the XML constructor rejects null. /// diff --git a/codemeta.json b/codemeta.json index a3be965..f44b275 100644 --- a/codemeta.json +++ b/codemeta.json @@ -4,9 +4,9 @@ "name": "Numerics: A .NET Library for Numerical Computing, Statistical Analysis, and Risk Assessment", "alternateName": "Numerics", "description": "A free and open-source .NET library providing numerical methods, probability distributions, statistical analysis, and Bayesian inference tools for quantitative risk assessment in water resources engineering.", - "version": "2.1.3", + "version": "2.1.4", "dateCreated": "2023-09-28", - "dateModified": "2026-07-15", + "dateModified": "2026-07-17", "license": "https://spdx.org/licenses/0BSD", "codeRepository": "https://github.com/USACE-RMC/Numerics", "issueTracker": "https://github.com/USACE-RMC/Numerics/issues",