Skip to content

Commit 2e79779

Browse files
committed
Add bit-faithful native NNS.part partitioner for the off-noise path
nns_part is the recursive partitioner at the heart of NNS.reg, which nns_arma drives per-lag, per-step in its nonlin/both recursion. It was pure Python with per-depth numpy unique/char operations on small arrays. This ports the noise_reduction="off" path (the only mode the numeric NNS.reg / nns_arma hot path uses) faithfully to the C++ binding layer as nns_part_off, aggregating centers and regression points with the previously added gravity_exact. The Python wrapper routes through it via the _native fallback and keeps the pure-Python implementation for the mean/median/mode paths. Verified bit-exact against the pure-Python nns_part over 8000 randomized cases (quadrant labels, prior labels, order, and regression-point values all identical; worst value diff 0.0). Full suite stays green; the nonlin ARMA forecast is bit-for-bit identical with native on vs off and the cumulative native path (gravity + partition) is ~1.9x faster on a representative series.
1 parent 19e1eee commit 2e79779

2 files changed

Lines changed: 210 additions & 1 deletion

File tree

src/nns/_nnscore_bindings.cpp

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
#include <nanobind/stl/vector.h>
55

66
#include <algorithm>
7+
#include <climits>
78
#include <cmath>
89
#include <cstdint>
910
#include <cstddef>
1011
#include <limits>
12+
#include <map>
1113
#include <stdexcept>
1214
#include <string>
1315
#include <vector>
@@ -360,6 +362,173 @@ double gravity_exact_impl(const double* data, std::size_t raw_n) {
360362
return 0.25 * (q2 + mode_gravity + mean + 0.5 * (q1 + q3));
361363
}
362364

365+
// Bit-faithful port of the pure-Python NNS.part recursive partitioner
366+
// (`nns.part.nns_part`) for the noise_reduction="off" path that the NNS.reg
367+
// numeric regression / nns_arma hot path uses exclusively. Centers and
368+
// regression points are aggregated with gravity_exact, matching the Python
369+
// `_gravity`. The caller (Python wrapper) resolves max_order exactly as
370+
// nns_part does and falls back to pure Python for the mean/median/mode paths,
371+
// so this only implements the gravity ("off") aggregation.
372+
nb::dict nns_part_off(const Vector& xa, const Vector& ya, bool xonly, int max_order, int obs_req,
373+
bool min_obs_stop) {
374+
const std::size_t n = checked_size(xa, "x");
375+
if (ya.shape(0) != n) {
376+
throw std::invalid_argument("x and y must have the same length.");
377+
}
378+
const double* x = xa.data();
379+
const double* y = ya.data();
380+
381+
// floor_order = floor(log2(max(1, n))), computed exactly via bit position.
382+
int floor_order = 0;
383+
{
384+
std::size_t v = (n < 1U) ? 1U : n;
385+
while ((static_cast<std::size_t>(1) << (floor_order + 1)) <= v) {
386+
++floor_order;
387+
}
388+
}
389+
if (max_order == 0) {
390+
max_order = 1;
391+
}
392+
393+
std::vector<std::string> quad(n, "q");
394+
std::vector<std::string> prior(n, "pq");
395+
int depth = 0;
396+
397+
while (depth < max_order && depth < floor_order) {
398+
// np.unique(quad) -> sorted labels, inverse, counts (std::map sorts keys).
399+
std::map<std::string, int> label_idx;
400+
for (const std::string& s : quad) {
401+
label_idx.emplace(s, 0);
402+
}
403+
int g = 0;
404+
std::vector<std::string> labels;
405+
labels.reserve(label_idx.size());
406+
for (auto& kv : label_idx) {
407+
kv.second = g++;
408+
labels.push_back(kv.first);
409+
}
410+
const int ngroups = g;
411+
std::vector<int> counts(ngroups, 0);
412+
std::vector<std::vector<std::size_t>> members(ngroups);
413+
for (std::size_t i = 0; i < n; ++i) {
414+
const int gi = label_idx[quad[i]];
415+
counts[gi] += 1;
416+
members[gi].push_back(i);
417+
}
418+
419+
bool any_split = false;
420+
for (int gi = 0; gi < ngroups; ++gi) {
421+
if (counts[gi] <= obs_req) {
422+
continue;
423+
}
424+
any_split = true;
425+
std::vector<double> gx;
426+
std::vector<double> gy;
427+
gx.reserve(members[gi].size());
428+
gy.reserve(members[gi].size());
429+
for (const std::size_t i : members[gi]) {
430+
gx.push_back(x[i]);
431+
gy.push_back(y[i]);
432+
}
433+
const double cx = gravity_exact_impl(gx.data(), gx.size());
434+
const double cy = xonly ? 0.0 : gravity_exact_impl(gy.data(), gy.size());
435+
for (const std::size_t i : members[gi]) {
436+
prior[i] = labels[gi];
437+
if (xonly) {
438+
const bool low_x = std::isfinite(x[i]) && std::isfinite(cx) && (x[i] > cx);
439+
quad[i] += (low_x ? '2' : '1');
440+
} else {
441+
const bool low_x = std::isfinite(x[i]) && std::isfinite(cx) && (x[i] <= cx);
442+
const bool low_y = std::isfinite(y[i]) && std::isfinite(cy) && (y[i] <= cy);
443+
const int qn = 1 + (low_x ? 1 : 0) + 2 * (low_y ? 1 : 0);
444+
quad[i] += static_cast<char>('0' + qn);
445+
}
446+
}
447+
}
448+
if (!any_split) {
449+
break;
450+
}
451+
++depth;
452+
453+
if (min_obs_stop) {
454+
std::map<std::string, int> post_counts;
455+
for (const std::string& s : quad) {
456+
post_counts[s] += 1;
457+
}
458+
int min_count = INT_MAX;
459+
for (const auto& kv : post_counts) {
460+
min_count = std::min(min_count, kv.second);
461+
}
462+
if (min_count <= obs_req) {
463+
break;
464+
}
465+
}
466+
}
467+
468+
// regression points grouped by prior.quadrant (sorted labels via std::map).
469+
std::map<std::string, std::vector<std::size_t>> prior_groups;
470+
for (std::size_t i = 0; i < n; ++i) {
471+
prior_groups[prior[i]].push_back(i);
472+
}
473+
std::vector<std::string> rp_quadrant;
474+
std::vector<double> rp_x;
475+
std::vector<double> rp_y;
476+
rp_quadrant.reserve(prior_groups.size());
477+
rp_x.reserve(prior_groups.size());
478+
rp_y.reserve(prior_groups.size());
479+
for (const auto& kv : prior_groups) {
480+
std::vector<double> gx;
481+
std::vector<double> gy;
482+
gx.reserve(kv.second.size());
483+
gy.reserve(kv.second.size());
484+
for (const std::size_t i : kv.second) {
485+
gx.push_back(x[i]);
486+
gy.push_back(y[i]);
487+
}
488+
rp_quadrant.push_back(kv.first);
489+
rp_x.push_back(gravity_exact_impl(gx.data(), gx.size()));
490+
rp_y.push_back(gravity_exact_impl(gy.data(), gy.size()));
491+
}
492+
493+
// _is_discrete_like_r(x) -> round regression-point x half-up.
494+
bool any_finite = false;
495+
bool all_integral = true;
496+
for (std::size_t i = 0; i < n; ++i) {
497+
if (std::isfinite(x[i])) {
498+
any_finite = true;
499+
if (x[i] != std::floor(x[i])) {
500+
all_integral = false;
501+
break;
502+
}
503+
}
504+
}
505+
if (any_finite && all_integral) {
506+
for (double& v : rp_x) {
507+
const double f = std::floor(v);
508+
v = (v - f < 0.5) ? f : std::ceil(v);
509+
}
510+
}
511+
512+
std::vector<double> dt_x(x, x + n);
513+
std::vector<double> dt_y(y, y + n);
514+
nb::dict dt;
515+
dt["x"] = std::move(dt_x);
516+
dt["y"] = std::move(dt_y);
517+
dt["quadrant"] = std::move(quad);
518+
dt["prior.quadrant"] = std::move(prior);
519+
520+
nb::dict regression_points;
521+
regression_points["quadrant"] = std::move(rp_quadrant);
522+
regression_points["x"] = std::move(rp_x);
523+
regression_points["y"] = std::move(rp_y);
524+
525+
nb::dict out;
526+
out["order"] = depth;
527+
out["dt"] = dt;
528+
out["regression.points"] = regression_points;
529+
return out;
530+
}
531+
363532
} // namespace
364533

365534
NB_MODULE(_nnscore, m) {
@@ -494,4 +663,6 @@ NB_MODULE(_nnscore, m) {
494663
m.def("gravity_exact", [](const Vector& x) {
495664
return gravity_exact_impl(x.data(), x.shape(0));
496665
});
666+
m.def("nns_part_off", &nns_part_off, nb::arg("x"), nb::arg("y"), nb::arg("xonly"),
667+
nb::arg("max_order"), nb::arg("obs_req"), nb::arg("min_obs_stop"));
497668
}

src/nns/part.py

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

33
import math
4-
from typing import Literal, TypeAlias, TypedDict, cast
4+
from typing import Any, Literal, TypeAlias, TypedDict, cast
55

66
import numpy as np
77
from numpy.typing import NDArray
88

9+
from nns._native import nnscore
910
from nns.central_tendencies import _nearest_int_half_up_array, nns_mode
1011
from nns.dependence import _gravity
1112

@@ -69,6 +70,19 @@ def nns_part(
6970

7071
xonly = type is not None
7172
n = x_values.size
73+
74+
native = nnscore()
75+
if native is not None and hasattr(native, "nns_part_off") and noise == "off":
76+
res = native.nns_part_off(
77+
np.ascontiguousarray(x_values),
78+
np.ascontiguousarray(y_values),
79+
xonly,
80+
int(max_order),
81+
int(obs_req),
82+
bool(min_obs_stop),
83+
)
84+
return _part_result_from_native(res, x_values, y_values)
85+
7286
floor_order = math.floor(math.log2(max(1, n)))
7387
quadrants = np.full(n, "q", dtype=f"<U{max_order + 1}")
7488
prior_quadrants = np.full(n, "pq", dtype=f"<U{max_order + 1}")
@@ -132,6 +146,30 @@ def nns_part(
132146
}
133147

134148

149+
def _part_result_from_native(
150+
res: dict[str, Any],
151+
x_values: NDArray[np.float64],
152+
y_values: NDArray[np.float64],
153+
) -> PartResult:
154+
"""Rebuild the NNS.part payload from the native nns_part_off result."""
155+
dt = res["dt"]
156+
rp = res["regression.points"]
157+
return {
158+
"order": int(res["order"]),
159+
"dt": {
160+
"x": x_values.copy(),
161+
"y": y_values.copy(),
162+
"quadrant": np.asarray(dt["quadrant"], dtype=str),
163+
"prior.quadrant": np.asarray(dt["prior.quadrant"], dtype=str),
164+
},
165+
"regression.points": {
166+
"quadrant": np.asarray(rp["quadrant"], dtype=str),
167+
"x": np.asarray(rp["x"], dtype=np.float64),
168+
"y": np.asarray(rp["y"], dtype=np.float64),
169+
},
170+
}
171+
172+
135173
def _centers_for_groups(
136174
x: NDArray[np.float64],
137175
y: NDArray[np.float64],

0 commit comments

Comments
 (0)