Skip to content

Commit acce968

Browse files
Merge pull request #132 from OVVO-Financial/claude/dy-d-v057-restore
diff: add dy_d_best — reconciled dy.d_ (dy.dx step + NNS.stack on X*)
2 parents ab73c26 + 93b5294 commit acce968

3 files changed

Lines changed: 371 additions & 0 deletions

File tree

src/nns/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"d_lpm": ("nns.co_moments", "d_lpm"),
2727
"dpm_nd": ("nns.dependence", "dpm_nd"),
2828
"dy_d": ("nns.diff", "dy_d"),
29+
"dy_d_best": ("nns.diff", "dy_d_best"),
2930
"dy_dx": ("nns.diff", "dy_dx"),
3031
"d_upm": ("nns.co_moments", "d_upm"),
3132
"ecdf_pm": ("nns.classical", "ecdf_pm"),

src/nns/diff.py

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,299 @@ def _combine_dy_d_outputs(
262262
}
263263

264264

265+
def dy_d_best(
266+
x: NDArray[Any],
267+
y: NDArray[Any],
268+
wrt: int | NDArray[np.int64],
269+
eval_points: str | float | NDArray[np.float64] = "obs",
270+
*,
271+
mixed: bool = False,
272+
messages: bool = False,
273+
factor_levels: Sequence[Sequence[Any] | None] | None = None,
274+
) -> dict[str, NDArray[np.float64]]:
275+
"""Reconciled ``dy.d_`` partial-derivative estimator.
276+
277+
This is the reconciliation of the original NNS 0.5.7 ``dy.d_`` (Vinod & Viole
278+
2020, SSRN 3681436) with the current regression engine. It keeps the v0.5.7
279+
finite-difference scaffolding but makes two changes that make the estimates
280+
uniform across identically-distributed regressors and independent of the old
281+
data.table machinery:
282+
283+
* **Step (``h_step``) shares the ``dy.dx`` logic** - a locally-adaptive step
284+
centred on the evaluation point's percentile,
285+
``h_step = VaR(p + H, 1, x) - VaR(p - H, 1, x)`` with
286+
``p = LPM.ratio(1, eval, x)`` - instead of a global quantile spacing with a
287+
cumulative window. This is what removes the cross-regressor scatter.
288+
* **Estimates come from** ``nns_stack`` on the equal-weight synthetic regressor
289+
``X*`` via the increased-dimension trick ``cbind(X*, X*)``, with
290+
``method=(1, 2)``, ``dim_red_method="equal"``, ``order="max"`` and
291+
``folds=5``. The stack's cross-validated ``n.best`` regularises the (sharper)
292+
current engine back toward the paper's regime.
293+
294+
Bandwidths follow v0.5.7: ``h_s = 1/log(size(x), [2, 10])`` extended by
295+
``10 * h_s`` and doubled when ``nns_dep(x[:, wrt], y) < 0.5``. First and second
296+
derivatives use the central-difference forms
297+
``First = (upper - lower) / (2 h_step)`` and
298+
``Second = (upper - 2 f(x) + lower) / h_step ** 2`` (matching ``dy.dx``),
299+
blended across bandwidths with a plain ``nanmean``.
300+
301+
``wrt`` uses R-style 1-based indexing into the factor-expanded predictor
302+
matrix. A scalar/1-D ``eval_points`` evaluates only the selected regressor
303+
(averaged over the distribution of the other regressors); a 2-D array
304+
evaluates complete predictor tuples.
305+
"""
306+
raw_x = np.asarray(x)
307+
if raw_x.ndim != 2:
308+
raise ValueError("Please ensure (x) is a matrix or data.frame type object.")
309+
if raw_x.shape[1] < 2:
310+
raise ValueError("Please use NNS::dy.dx(...) for univariate partial derivatives.")
311+
312+
raw_y = np.asarray(y).reshape(-1)
313+
if raw_y.size != raw_x.shape[0]:
314+
raise ValueError("x and y must have compatible row counts.")
315+
if _dy_d_has_missing(raw_x) or _dy_d_has_missing(raw_y):
316+
raise ValueError("You have some missing values, please address.")
317+
318+
x_values = _dy_d_expand_predictors(x, factor_levels=factor_levels)
319+
y_values = np.asarray(raw_y, dtype=np.float64)
320+
if _dy_d_has_missing(x_values) or _dy_d_has_missing(y_values):
321+
raise ValueError("You have some missing values, please address.")
322+
323+
wrt_values = _dy_d_validate_wrt(wrt, x_values.shape[1])
324+
outputs = [
325+
_dy_d_best_scalar(
326+
x_values,
327+
y_values,
328+
int(wrt_value) - 1,
329+
eval_points,
330+
mixed=bool(mixed),
331+
messages=bool(messages),
332+
wrt_label=int(wrt_value),
333+
)
334+
for wrt_value in wrt_values
335+
]
336+
if len(outputs) == 1:
337+
return outputs[0]
338+
return _combine_dy_d_outputs(outputs)
339+
340+
341+
def _dy_d_best_reg_estimates(
342+
x: NDArray[np.float64],
343+
y: NDArray[np.float64],
344+
test_points: NDArray[np.float64],
345+
) -> NDArray[np.float64]:
346+
"""f(x +/- h) via NNS.stack on the equal-weight synthetic regressor X*.
347+
348+
Reduces both the training design and the test points to the equal-weight
349+
synthetic regressor X* = rowMeans(.), then estimates with the NNS
350+
increased-dimension trick cbind(X*, X*) under
351+
``method=(1, 2), dim_red_method="equal", order="max", folds=5``. The
352+
cross-validated ``n.best`` is what regularises the current engine.
353+
"""
354+
from nns.stack import nns_stack
355+
356+
x_star = np.asarray(x, dtype=np.float64).mean(axis=1)
357+
test_star = np.asarray(test_points, dtype=np.float64).mean(axis=1)
358+
result = nns_stack(
359+
ivs_train=np.column_stack([x_star, x_star]),
360+
dv_train=y,
361+
ivs_test=np.column_stack([test_star, test_star]),
362+
method=(1, 2),
363+
dim_red_method="equal",
364+
status=False,
365+
order="max",
366+
folds=5,
367+
ncores=1,
368+
dist=None,
369+
)
370+
return np.asarray(result["stack"], dtype=np.float64).reshape(-1)
371+
372+
373+
def _r_seq(start: float, stop: float, by: float) -> NDArray[np.float64]:
374+
"""Reproduce R's seq(start, stop, by)."""
375+
if not np.isfinite(by) or by == 0.0:
376+
return np.asarray([start], dtype=np.float64)
377+
count = int(np.floor((stop - start) / by + 1e-9))
378+
return start + by * np.arange(0, count + 1, dtype=np.float64)
379+
380+
381+
def _v057_bandwidths(x_size: int, wrt_dependence: float) -> NDArray[np.float64]:
382+
"""h_s = 1/log(length(x), c(2, 10)); c(h_s, 10*h_s); doubled if dependence < .5."""
383+
base = np.log(np.asarray([2.0, 10.0])) / np.log(float(x_size))
384+
h_s = np.concatenate([base, 10.0 * base])
385+
if wrt_dependence < 0.5:
386+
h_s = 2.0 * h_s
387+
return h_s
388+
389+
390+
def _dydx_step(column: NDArray[np.float64], eval_value: float, band: float) -> float:
391+
"""Locally-adaptive dy.dx step: VaR(p + H, 1, x) - VaR(p - H, 1, x), p = CDF(eval)."""
392+
from nns.core import lpm_ratio
393+
from nns.var import lpm_var
394+
395+
p = float(lpm_ratio(1.0, float(eval_value), column))
396+
upper = lpm_var(min(1.0, p + band), 1.0, column)
397+
lower = lpm_var(max(0.0, p - band), 1.0, column)
398+
return float(upper - lower)
399+
400+
401+
def _dy_d_best_scalar(
402+
x_values: NDArray[np.float64],
403+
y_values: NDArray[np.float64],
404+
wrt_index: int,
405+
eval_points: str | float | NDArray[np.float64],
406+
*,
407+
mixed: bool,
408+
messages: bool,
409+
wrt_label: int,
410+
) -> dict[str, NDArray[np.float64]]:
411+
from nns.dependence import nns_dep
412+
from nns.var import lpm_var
413+
414+
_n_rows, n_predictors = x_values.shape
415+
if wrt_index < 0 or wrt_index >= n_predictors:
416+
raise ValueError("`wrt` must select exactly one column of the expanded predictor matrix.")
417+
if n_predictors != 2:
418+
mixed = False
419+
420+
if messages:
421+
print(
422+
"Currently generating NNS.reg finite difference estimates...Regressor "
423+
f"{wrt_label}\r"
424+
)
425+
426+
eval_values, vector_branch = _dy_d_eval_points(x_values, wrt_index, eval_points)
427+
428+
column = x_values[:, wrt_index]
429+
dependence = float(nns_dep(column, y_values)["Dependence"])
430+
h_s = _v057_bandwidths(x_values.size, dependence)
431+
432+
firsts: list[NDArray[np.float64]] = []
433+
seconds: list[NDArray[np.float64]] = []
434+
mixeds: list[NDArray[np.float64]] = []
435+
436+
if vector_branch:
437+
eval_vec = np.asarray(eval_values, dtype=np.float64).reshape(-1)
438+
grid = np.column_stack(
439+
[
440+
np.asarray(
441+
[lpm_var(float(p), 0.0, x_values[:, col]) for p in _r_seq(0.0, 1.0, 0.05)],
442+
dtype=np.float64,
443+
)
444+
for col in range(n_predictors)
445+
]
446+
)
447+
sample_size = grid.shape[0]
448+
k = eval_vec.size
449+
for band in h_s:
450+
steps = np.array([_dydx_step(column, ev, float(band)) for ev in eval_vec])
451+
blocks: list[NDArray[np.float64]] = []
452+
position: list[str] = []
453+
ids: list[int] = []
454+
for g in range(k):
455+
lower = grid.copy()
456+
middle = grid.copy()
457+
upper = grid.copy()
458+
lower[:, wrt_index] = eval_vec[g] - steps[g]
459+
middle[:, wrt_index] = eval_vec[g]
460+
upper[:, wrt_index] = eval_vec[g] + steps[g]
461+
blocks.extend((lower, middle, upper))
462+
position.extend(["l"] * sample_size + ["m"] * sample_size + ["u"] * sample_size)
463+
ids.extend([g] * (3 * sample_size))
464+
estimates = _dy_d_best_reg_estimates(x_values, y_values, np.vstack(blocks))
465+
pos = np.asarray(position, dtype=object)
466+
idx = np.asarray(ids)
467+
band_first = np.empty(k)
468+
band_second = np.empty(k)
469+
for g in range(k):
470+
lo = np.mean(estimates[(pos == "l") & (idx == g)])
471+
mid = np.mean(estimates[(pos == "m") & (idx == g)])
472+
up = np.mean(estimates[(pos == "u") & (idx == g)])
473+
h = steps[g]
474+
if np.isfinite(h) and h != 0.0:
475+
band_first[g] = (up - lo) / (2.0 * h)
476+
band_second[g] = (up - 2.0 * mid + lo) / (h**2)
477+
else:
478+
band_first[g] = np.nan
479+
band_second[g] = np.nan
480+
firsts.append(band_first)
481+
seconds.append(band_second)
482+
mixed_eval: NDArray[np.float64] | None = None
483+
else:
484+
eval_mat = _as_eval_matrix(eval_values, n_predictors)
485+
n_eval = eval_mat.shape[0]
486+
for band in h_s:
487+
steps = np.array(
488+
[_dydx_step(column, eval_mat[i, wrt_index], float(band)) for i in range(n_eval)]
489+
)
490+
finite = np.isfinite(steps) & (steps != 0.0)
491+
lower = eval_mat.copy()
492+
upper = eval_mat.copy()
493+
lower[:, wrt_index] = eval_mat[:, wrt_index] - steps
494+
upper[:, wrt_index] = eval_mat[:, wrt_index] + steps
495+
if messages:
496+
print(
497+
"Currently generating NNS.reg finite difference estimates...bandwidth\r"
498+
)
499+
estimates = _dy_d_best_reg_estimates(
500+
x_values, y_values, np.vstack((lower, eval_mat, upper))
501+
)
502+
lo = estimates[:n_eval]
503+
mid = estimates[n_eval : 2 * n_eval]
504+
up = estimates[2 * n_eval :]
505+
with np.errstate(invalid="ignore", divide="ignore"):
506+
first = (up - lo) / (2.0 * steps)
507+
second = (up - 2.0 * mid + lo) / (steps**2)
508+
first[~finite] = np.nan
509+
second[~finite] = np.nan
510+
firsts.append(first)
511+
seconds.append(second)
512+
mixed_eval = eval_mat
513+
514+
def _row_nanmean(bands: list[NDArray[np.float64]]) -> NDArray[np.float64]:
515+
matrix = np.column_stack([np.asarray(b, dtype=np.float64).reshape(-1) for b in bands])
516+
with np.errstate(invalid="ignore"):
517+
return np.asarray(np.nanmean(matrix, axis=1), dtype=np.float64)
518+
519+
output = {"First": _row_nanmean(firsts), "Second": _row_nanmean(seconds)}
520+
521+
if mixed:
522+
if vector_branch:
523+
tuple_eval = np.asarray(eval_values, dtype=np.float64).reshape(-1)
524+
if tuple_eval.size != 2:
525+
raise ValueError("Mixed Derivatives are only for 2 IV")
526+
mixed_points = tuple_eval.reshape(1, 2)
527+
else:
528+
assert mixed_eval is not None
529+
if mixed_eval.shape[1] != 2:
530+
raise ValueError("Mixed Derivatives are only for 2 IV")
531+
mixed_points = mixed_eval
532+
for band in h_s:
533+
band_vals: list[float] = []
534+
for point in mixed_points:
535+
s1 = _dydx_step(x_values[:, 0], point[0], float(band))
536+
s2 = _dydx_step(x_values[:, 1], point[1], float(band))
537+
if not (np.isfinite(s1) and np.isfinite(s2) and s1 != 0.0 and s2 != 0.0):
538+
band_vals.append(np.nan)
539+
continue
540+
corners = np.array(
541+
[
542+
[point[0] + s1, point[1] + s2],
543+
[point[0] - s1, point[1] + s2],
544+
[point[0] + s1, point[1] - s2],
545+
[point[0] - s1, point[1] - s2],
546+
]
547+
)
548+
z = _dy_d_best_reg_estimates(x_values, y_values, corners)
549+
band_vals.append((z[0] + z[3] - z[1] - z[2]) / (4.0 * s1 * s2))
550+
mixeds.append(np.asarray(band_vals, dtype=np.float64))
551+
output["Mixed"] = _row_nanmean(mixeds)
552+
553+
if messages:
554+
print("\r")
555+
return output
556+
557+
265558
def _dy_d_scalar(
266559
x_values: NDArray[np.float64],
267560
y_values: NDArray[np.float64],

tests/invariants/test_diff.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,80 @@ def test_dy_d_point_modes_preserve_linear_slope_direction() -> None:
115115
for eval_points in ("mean", "median", "last"):
116116
assert dy_d(x, positive, wrt=1, eval_points=eval_points)["First"][0] > 0.0
117117
assert dy_d(x, negative, wrt=1, eval_points=eval_points)["First"][0] < 0.0
118+
119+
120+
def test_dy_d_best_is_exported() -> None:
121+
import nns
122+
123+
assert "dy_d_best" in nns.__all__
124+
assert callable(nns.dy_d_best)
125+
126+
127+
def test_dy_d_best_vectorized_wrt_tuple_returns_first_second() -> None:
128+
from nns import dy_d_best
129+
130+
x = np.random.RandomState(0).randn(60, 3)
131+
y = x[:, 0] + 2.0 * x[:, 1] - x[:, 2]
132+
133+
result = dy_d_best(x, y, wrt=np.array([1, 2, 3]), eval_points=np.zeros((1, 3)))
134+
135+
assert result.keys() == {"First", "Second"}
136+
assert result["First"].shape == (1, 3)
137+
assert result["Second"].shape == (1, 3)
138+
assert np.all(np.isfinite(result["First"]))
139+
140+
141+
def test_dy_d_best_single_wrt_returns_one_dimensional_first() -> None:
142+
from nns import dy_d_best
143+
144+
x = np.random.RandomState(0).randn(60, 3)
145+
y = x[:, 0] + 2.0 * x[:, 1] - x[:, 2]
146+
147+
# A single `wrt` returns 1-D arrays (one value per evaluation point).
148+
result = dy_d_best(x, y, wrt=1, eval_points=np.array([[0.0, 0.0, 0.0]]))
149+
150+
assert result["First"].shape == (1,)
151+
assert np.all(np.isfinite(result["First"]))
152+
153+
154+
def test_dy_d_best_two_column_mixed_returns_mixed() -> None:
155+
from nns import dy_d_best
156+
157+
x = np.random.RandomState(1).randn(60, 2)
158+
y = x[:, 0] * x[:, 1]
159+
160+
result = dy_d_best(x, y, wrt=np.array([1, 2]), eval_points=np.array([[0.0, 0.0]]), mixed=True)
161+
162+
assert result.keys() == {"First", "Second", "Mixed"}
163+
assert result["First"].shape == (1, 2)
164+
assert result["Mixed"].shape == (1, 2)
165+
166+
167+
def test_dy_d_best_is_deterministic() -> None:
168+
from nns import dy_d_best
169+
170+
x = np.random.RandomState(0).randn(60, 3)
171+
y = x[:, 0] + 2.0 * x[:, 1] - x[:, 2]
172+
173+
a = dy_d_best(x, y, wrt=np.array([1, 2, 3]), eval_points=np.zeros((1, 3)))
174+
b = dy_d_best(x, y, wrt=np.array([1, 2, 3]), eval_points=np.zeros((1, 3)))
175+
176+
assert np.allclose(a["First"], b["First"])
177+
assert np.allclose(a["Second"], b["Second"])
178+
179+
180+
def test_dy_d_best_matches_pinned_values() -> None:
181+
# Golden values guard the v0.5.7 finite-difference design against regressions.
182+
from nns import dy_d_best
183+
184+
x = np.random.RandomState(0).randn(60, 3)
185+
y = x[:, 0] + 2.0 * x[:, 1] - x[:, 2]
186+
187+
result = dy_d_best(x, y, wrt=np.array([1, 2, 3]), eval_points=np.zeros((1, 3)))
188+
189+
np.testing.assert_allclose(
190+
np.ravel(result["First"]),
191+
np.array([0.754963, 0.792164, 0.699243]),
192+
rtol=0.0,
193+
atol=1e-4,
194+
)

0 commit comments

Comments
 (0)