Skip to content

Commit 18d5905

Browse files
Merge pull request #80 from OVVO-Financial/claude/book-python-code-integration-xijzxt
Support unequal-length samples in stochastic dominance and nns_norm
2 parents 1542fca + 7ec10f9 commit 18d5905

7 files changed

Lines changed: 227 additions & 15 deletions

File tree

docs/api_reference.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,11 @@ NNS ANOVA-style comparison helper covering binary, multi-group, pairwise, and de
315315

316316
Closest R API: `NNS.norm`.
317317

318-
NNS normalization helper for numeric matrix-style inputs.
318+
NNS normalization helper. Accepts a 2-D matrix or, like R's list input, a
319+
sequence of 1-D vectors (one per variable). Equal-length vectors are
320+
column-stacked and normalized through the matrix path; unequal-length
321+
vectors force `linear=True` exactly as R does and return a list of scaled
322+
arrays.
319323

320324
#### `nns_distance`
321325

@@ -361,13 +365,17 @@ Rescales inputs using NNS conventions.
361365

362366
Closest R APIs: `NNS.FSD`, `NNS.SSD`, and `NNS.TSD`.
363367

364-
Compute first-, second-, and third-order stochastic dominance.
368+
Compute first-, second-, and third-order stochastic dominance. The two samples
369+
may differ in length; curves are evaluated on the merged threshold grid exactly
370+
as the R functions do.
365371

366372
#### `fsd_uni`, `ssd_uni`, `tsd_uni`
367373

368374
Closest R APIs: `NNS.FSD.uni`, `NNS.SSD.uni`, and `NNS.TSD.uni`.
369375

370-
Univariate wrappers for stochastic dominance workflows.
376+
Univariate wrappers for stochastic dominance workflows. Unlike R's C++ `.uni`
377+
routines, unequal-length samples are supported with the same merged-grid
378+
semantics as the pairwise tests.
371379

372380
#### `nns_sd_cluster`
373381

docs/api_status.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ invariant, and property coverage.
5757
| Bootstrap/Monte Carlo: `nns_meboot`, `nns_mc` | implemented | medium | Deterministic diagnostics are parity-tested; exact stochastic replicate parity with R is not expected. |
5858
| Stochastic dominance/superiority: `fsd`, `ssd`, `tsd`, `.uni` wrappers, `nns_ss`, `nns_sd_cluster`, `sd_efficient_set` | implemented | medium | Public structures and deterministic paths are covered. SD uses exact pure-NumPy prefix-pair kernels plus a degree-1 discrete order-statistic matrix path; R's C++ core remains faster on full finance fixtures. Stochastic intervals use NNS Python RNG. |
5959
| ANOVA: `nns_anova` | implemented | high | Binary, multi-group, pairwise, and degenerate `NaN` conventions are covered. |
60-
| Normalization: `nns_norm` | implemented | high | Numeric matrix path is implemented. |
60+
| Normalization: `nns_norm` | implemented | high | Numeric matrix path and R's list-of-vectors path are implemented; unequal-length vectors force linear scaling as in R. |
6161
| Categorical helpers: `encode_factor_codes`, `factor_2_dummy`, `factor_2_dummy_fr`, `prepare_factor_predictors` | implemented | high | Explicit `levels=` / `factor_levels=` should be used to reproduce R factor ordering. `prepare_factor_predictors(...)` exposes the regression-ready full-rank design matrix path. |
6262
| Scalar differentiation: `nns_diff`, `dy_dx` | implemented | high | `dy_dx(..., eval_point="overall")` and numeric evaluation points are covered. |
6363
| Multivariate differentiation: `dy_d` | partial | medium-high | Scalar and vectorized point/distribution modes are covered on focused fixtures. Mixed derivatives are supported for two-regressor inputs where defined; multi-row matrix mixed derivatives use pointwise Python semantics rather than R's order-dependent list-matrix packing quirk. |

docs/conventions.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,14 @@ NNS Python does not plot the dendrogram; it only returns the object data.
7373
The stochastic-dominance implementation is deliberately pure NumPy. It mirrors
7474
R's C++ SD core mathematically by sorting each column once, storing prefix sums,
7575
and evaluating dominance on each pair's merged threshold grid rather than on one
76-
global all-column grid. The full prefix-pair dominance matrix remains available
76+
global all-column grid. Pairwise tests (`fsd`, `ssd`, `tsd`, and the `*_uni`
77+
wrappers) accept samples of unequal length: each sample's curve is evaluated on
78+
the merged grid exactly as R's `NNS.FSD`/`NNS.SSD`/`NNS.TSD` compute
79+
`LPM(degree, sort(c(x, y)), sample)` per sample. R's C++ `.uni` walkers assume
80+
equal-length inputs, so for unequal lengths the Python `*_uni` wrappers are an
81+
intentional extension carrying the same merged-grid semantics. The
82+
matrix-based efficient-set and cluster routines operate on data columns and
83+
therefore remain equal-length by construction. The full prefix-pair dominance matrix remains available
7784
internally for verification and fallback. Large degree-1 discrete calls use an
7885
exact order-statistic dominance matrix: with equal-length empirical samples,
7986
one sample first-order stochastically dominates another exactly when every

src/nns/norm.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,52 @@
11
from __future__ import annotations
22

3-
from typing import cast
3+
from collections.abc import Sequence
4+
from typing import cast, overload
45

56
import numpy as np
67
from numpy.typing import NDArray
78

89
from nns.dependence import nns_dep
910

1011

11-
def nns_norm(x: NDArray[np.float64], linear: bool = False) -> NDArray[np.float64]:
12-
"""Normalize a numeric matrix following R's NNS.norm scaling."""
13-
values = _as_matrix(x)
12+
@overload
13+
def nns_norm(x: NDArray[np.float64], linear: bool = ...) -> NDArray[np.float64]: ...
14+
15+
16+
@overload
17+
def nns_norm(
18+
x: Sequence[NDArray[np.float64]],
19+
linear: bool = ...,
20+
) -> NDArray[np.float64] | list[NDArray[np.float64]]: ...
21+
22+
23+
def nns_norm(
24+
x: NDArray[np.float64] | Sequence[NDArray[np.float64]],
25+
linear: bool = False,
26+
) -> NDArray[np.float64] | list[NDArray[np.float64]]:
27+
"""Normalize variables following R's NNS.norm scaling.
28+
29+
Two input conventions are supported, matching R's ``NNS.norm(X, ...)``:
30+
31+
* A 2-D array whose columns are variables. Returns the scaled 2-D array.
32+
* A list or tuple of 1-D arrays, one per variable (R's list input; the
33+
elements are variables/columns, not observation rows). Equal-length
34+
vectors are column-stacked and normalized through the matrix path,
35+
returning a 2-D array — mirroring R, where ``mapply`` simplifies the
36+
equal-length list result to a matrix. Unequal-length vectors force
37+
``linear=True`` exactly as R does (dependence-based scale factors need
38+
aligned columns) and return a list of scaled arrays, one per input
39+
vector.
40+
"""
41+
if isinstance(x, np.ndarray):
42+
values = _as_matrix(x)
43+
else:
44+
series = [_as_vector(item, index) for index, item in enumerate(x)]
45+
if not series:
46+
raise ValueError("x must be non-empty.")
47+
if len({item.size for item in series}) > 1:
48+
return _norm_unequal_series(series)
49+
values = _as_matrix(np.column_stack(series))
1450
means = np.mean(values, axis=0)
1551
means = means.copy()
1652
means[means == 0.0] = 1e-10
@@ -30,6 +66,14 @@ def nns_norm(x: NDArray[np.float64], linear: bool = False) -> NDArray[np.float64
3066
return scaled
3167

3268

69+
def _norm_unequal_series(series: list[NDArray[np.float64]]) -> list[NDArray[np.float64]]:
70+
means = np.array([float(np.mean(item)) for item in series])
71+
means[means == 0.0] = 1e-10
72+
ratio_grid = means[:, np.newaxis] * (1.0 / means[np.newaxis, :])
73+
scales = np.mean(ratio_grid, axis=0)
74+
return [item * scale for item, scale in zip(series, scales, strict=True)]
75+
76+
3377
def _scale_factor(values: NDArray[np.float64]) -> NDArray[np.float64]:
3478
if values.shape[1] < 10:
3579
return cast(NDArray[np.float64], np.abs(np.corrcoef(values, rowvar=False)))
@@ -44,6 +88,17 @@ def _scale_factor(values: NDArray[np.float64]) -> NDArray[np.float64]:
4488
return deps
4589

4690

91+
def _as_vector(x: NDArray[np.float64], index: int) -> NDArray[np.float64]:
92+
values = np.asarray(x, dtype=np.float64)
93+
if values.ndim != 1:
94+
raise ValueError(f"x[{index}] must be a 1D numeric vector.")
95+
if values.size == 0:
96+
raise ValueError(f"x[{index}] must be non-empty.")
97+
if not np.all(np.isfinite(values)):
98+
raise ValueError(f"x[{index}] must contain only finite values.")
99+
return values
100+
101+
47102
def _as_matrix(x: NDArray[np.float64]) -> NDArray[np.float64]:
48103
values = np.asarray(x, dtype=np.float64)
49104
if values.ndim != 2:

src/nns/stochastic_dominance.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44
curve equality guard: equal LPM/CDF curves are non-dominance even when samples
55
differ below meaningful double precision. Efficient-set output follows the R
66
C++ routine's LPM-at-global-maximum ordering and original-index tie break.
7+
8+
Pairwise tests accept samples of unequal length: each sample's LPM/CDF curve is
9+
evaluated on the merged threshold grid, exactly as R's NNS.FSD/NNS.SSD/NNS.TSD
10+
compute ``LPM(degree, sort(c(x, y)), sample)`` per sample. (R's C++ ``.uni``
11+
walkers assume equal lengths; the Python ``*_uni`` wrappers extend the same
12+
merged-grid semantics to unequal lengths.)
713
"""
814

915
from __future__ import annotations
@@ -55,7 +61,7 @@ class _SDOrderStatPrecomputed:
5561

5662

5763
def fsd(x: NDArray[np.float64], y: NDArray[np.float64]) -> int:
58-
"""First-order stochastic dominance."""
64+
"""First-order stochastic dominance; ``x`` and ``y`` may differ in length."""
5965
x_values = _as_sd_values(x, "x")
6066
y_values = _as_sd_values(y, "y")
6167
return _sd_result(x_values, y_values, 1)
@@ -70,7 +76,7 @@ def fsd_uni(x: NDArray[np.float64], y: NDArray[np.float64], type: str = "discret
7076

7177

7278
def ssd(x: NDArray[np.float64], y: NDArray[np.float64]) -> int:
73-
"""Second-order stochastic dominance."""
79+
"""Second-order stochastic dominance; ``x`` and ``y`` may differ in length."""
7480
x_values = _as_sd_values(x, "x")
7581
y_values = _as_sd_values(y, "y")
7682
return _sd_result(x_values, y_values, 2)
@@ -84,7 +90,7 @@ def ssd_uni(x: NDArray[np.float64], y: NDArray[np.float64]) -> int:
8490

8591

8692
def tsd(x: NDArray[np.float64], y: NDArray[np.float64]) -> int:
87-
"""Third-order stochastic dominance."""
93+
"""Third-order stochastic dominance; ``x`` and ``y`` may differ in length."""
8894
x_values = _as_sd_values(x, "x")
8995
y_values = _as_sd_values(y, "y")
9096
return _sd_result(x_values, y_values, 3)
@@ -754,8 +760,6 @@ def _dominates_uni(
754760
*,
755761
discrete: bool,
756762
) -> bool:
757-
if x.size != y.size:
758-
raise ValueError("x and y must have the same length.")
759763
if np.array_equal(np.sort(x), np.sort(y)):
760764
return False
761765
if np.min(x) < np.min(y):

tests/invariants/test_norm.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,60 @@ def test_nonlinear_nns_norm_preserves_shape_for_wide_matrix() -> None:
3434

3535
assert result.shape == x.shape
3636
assert np.all(np.isfinite(result))
37+
38+
39+
def test_equal_length_sequence_matches_matrix_path() -> None:
40+
x = np.linspace(1.0, 3.0, 50)
41+
y = np.linspace(2.0, 8.0, 50)
42+
z = np.linspace(10.0, 20.0, 50)
43+
matrix = np.column_stack((x, y, z))
44+
45+
for linear in (False, True):
46+
from_sequence = nns_norm([x, y, z], linear=linear)
47+
from_matrix = nns_norm(matrix, linear=linear)
48+
assert isinstance(from_sequence, np.ndarray)
49+
np.testing.assert_allclose(from_sequence, from_matrix)
50+
51+
52+
def test_unequal_length_sequence_forces_linear_scaling() -> None:
53+
vec1 = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0])
54+
vec2 = np.array([10.0, 20.0, 30.0, 40.0, 50.0, 60.0])
55+
vec3 = np.array([0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3])
56+
57+
result = nns_norm([vec1, vec2, vec3])
58+
59+
assert isinstance(result, list)
60+
assert [item.size for item in result] == [7, 6, 9]
61+
62+
# Linear scaling equalizes every scaled mean at the grand mean of means,
63+
# and the linear flag is forced regardless of its passed value (as in R).
64+
grand_mean = np.mean([vec1.mean(), vec2.mean(), vec3.mean()])
65+
for item in result:
66+
np.testing.assert_allclose(item.mean(), grand_mean)
67+
forced = nns_norm([vec1, vec2, vec3], linear=True)
68+
for got, expected in zip(result, forced, strict=True):
69+
np.testing.assert_allclose(got, expected)
70+
71+
72+
def test_zero_sum_length_differences_detected_as_unequal() -> None:
73+
# Lengths (5, 6, 5) have pairwise diffs summing to zero; they must still
74+
# take the unequal-length path rather than being treated as a matrix.
75+
vec1 = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
76+
vec2 = np.array([10.0, 20.0, 30.0, 40.0, 50.0, 60.0])
77+
vec3 = np.array([0.5, 0.6, 0.7, 0.8, 0.9])
78+
79+
result = nns_norm([vec1, vec2, vec3])
80+
81+
assert isinstance(result, list)
82+
assert [item.size for item in result] == [5, 6, 5]
83+
84+
85+
def test_sequence_input_validation() -> None:
86+
import pytest
87+
88+
with pytest.raises(ValueError, match="non-empty"):
89+
nns_norm([])
90+
with pytest.raises(ValueError, match=r"x\[1\] must be a 1D"):
91+
nns_norm([np.ones(3), np.ones((3, 2))])
92+
with pytest.raises(ValueError, match=r"x\[0\] must contain only finite"):
93+
nns_norm([np.array([1.0, np.nan]), np.ones(3)])

tests/invariants/test_stochastic_dominance.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,32 @@
11
from __future__ import annotations
22

33
import numpy as np
4+
import pytest
5+
from numpy.typing import NDArray
46

5-
from nns import fsd, ssd, tsd
7+
from nns import fsd, fsd_uni, lpm, ssd, ssd_uni, tsd, tsd_uni
8+
9+
10+
def _r_sd_reference(x: NDArray[np.float64], y: NDArray[np.float64], degree: int) -> int:
11+
"""R's NNS.FSD/NNS.SSD/NNS.TSD decision rule, transcribed literally."""
12+
grid = np.sort(np.concatenate((x, y)))
13+
if degree == 1:
14+
# LPM.ratio(0, grid, sample) == LPM(0, grid, sample) == ECDF
15+
curve_x = np.asarray(lpm(0, grid, x), dtype=np.float64)
16+
curve_y = np.asarray(lpm(0, grid, y), dtype=np.float64)
17+
else:
18+
curve_x = np.asarray(lpm(degree - 1, grid, x), dtype=np.float64)
19+
curve_y = np.asarray(lpm(degree - 1, grid, y), dtype=np.float64)
20+
21+
mean_ok_xy = degree == 1 or float(np.mean(x)) >= float(np.mean(y))
22+
mean_ok_yx = degree == 1 or float(np.mean(y)) >= float(np.mean(x))
23+
curves_identical = np.array_equal(curve_x, curve_y)
24+
25+
if not np.any(curve_x > curve_y) and x.min() >= y.min() and mean_ok_xy and not curves_identical:
26+
return 1
27+
if not np.any(curve_y > curve_x) and y.min() >= x.min() and mean_ok_yx and not curves_identical:
28+
return -1
29+
return 0
630

731

832
def test_sd_antisymmetry() -> None:
@@ -29,3 +53,60 @@ def test_self_does_not_dominate() -> None:
2953
assert fsd(x, x) == 0
3054
assert ssd(x, x) == 0
3155
assert tsd(x, x) == 0
56+
57+
58+
def test_unequal_length_shifted_samples_dominate() -> None:
59+
x = np.array([2.0, 3.0, 4.0, 5.0, 6.0])
60+
y = np.array([1.0, 2.0, 3.0])
61+
62+
assert fsd(x, y) == 1
63+
assert ssd(x, y) == 1
64+
assert tsd(x, y) == 1
65+
assert fsd(y, x) == -1
66+
67+
assert fsd_uni(x, y) == 1
68+
assert fsd_uni(x, y, "continuous") == 1
69+
assert ssd_uni(x, y) == 1
70+
assert tsd_uni(x, y) == 1
71+
assert fsd_uni(y, x) == 0
72+
assert ssd_uni(y, x) == 0
73+
assert tsd_uni(y, x) == 0
74+
75+
76+
def test_unequal_length_antisymmetry() -> None:
77+
rng = np.random.default_rng(7)
78+
x = rng.normal(1.0, 1.0, 37)
79+
y = rng.normal(0.0, 1.0, 61)
80+
81+
assert fsd(x, y) == -fsd(y, x)
82+
assert ssd(x, y) == -ssd(y, x)
83+
assert tsd(x, y) == -tsd(y, x)
84+
85+
86+
def test_mtcars_transmission_groups_match_r() -> None:
87+
# mtcars mpg split by transmission: R's NNS.FSD returns "X FSD Y" for
88+
# (manual, auto) despite the samples having different lengths (13 vs 19).
89+
auto_mpg = np.array(
90+
[21.4, 18.7, 18.1, 14.3, 24.4, 22.8, 19.2, 17.8, 16.4, 17.3,
91+
15.2, 10.4, 10.4, 14.7, 21.5, 15.5, 15.2, 13.3, 19.2]
92+
)
93+
manual_mpg = np.array(
94+
[21.0, 21.0, 22.8, 32.4, 30.4, 33.9, 27.3, 26.0, 30.4, 15.8, 19.7, 15.0, 21.4]
95+
)
96+
97+
assert fsd(manual_mpg, auto_mpg) == 1
98+
assert fsd(auto_mpg, manual_mpg) == -1
99+
assert fsd_uni(manual_mpg, auto_mpg) == 1
100+
101+
102+
@pytest.mark.parametrize("degree", [1, 2, 3])
103+
@pytest.mark.parametrize("seed", range(8))
104+
def test_unequal_length_matches_r_decision_rule(degree: int, seed: int) -> None:
105+
rng = np.random.default_rng(seed)
106+
sizes = rng.integers(3, 60, size=2)
107+
shift = rng.uniform(-0.5, 0.5)
108+
x = rng.normal(shift, 1.0, int(sizes[0]))
109+
y = rng.normal(0.0, rng.uniform(0.5, 1.5), int(sizes[1]))
110+
111+
function = {1: fsd, 2: ssd, 3: tsd}[degree]
112+
assert function(x, y) == _r_sd_reference(x, y, degree)

0 commit comments

Comments
 (0)