@@ -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+
265558def _dy_d_scalar (
266559 x_values : NDArray [np .float64 ],
267560 y_values : NDArray [np .float64 ],
0 commit comments