From d755f19f60228436a53a02d462ab3f01b3e1a486 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 19:49:35 +0000 Subject: [PATCH] boost: reconcile nns_boost with R 13.2 NNS.boost; version 2.0.0 Port of the reconciled R/Boost.R (OVVO-Financial/NNS PR #58, commit bb878ad): * threshold is a probability supplied to LPM.VaR over the learner-trial objective distribution (defaults 0.80 max / 0.20 min; extreme selects 1/0), never a literal score cutoff; survivor selection and every epoch re-test use the distinct LPM.VaR objective cutoff. * learner trials fit genuine multivariate NNS.reg (dim_red_method=None) and draw a fresh validation holdout per cross-sectional trial; time-series trials keep the chronological terminal block. * epochs always run (exhaustive enumeration no longer disables them), re-testing only surviving combinations with R's survivor-permutation scheduling, fresh holdouts (chronological expanding windows for ts). * final estimate replicates the original keeper predictors by relative epoch frequencies into one multivariate nns_stack(method=1, stack=False, folds=5, balance=False) call; the synthetic X* path is removed. New public folds argument (default 5). * result gains class.levels (None for regression), matching R. All draws replicate R's Mersenne-Twister stream via RRNG, making every seeded path bit-for-bit identical to R - including balance=TRUE, closing the formerly xfail-characterized balanced-boost sampling gap (the iris classification vignette now matches installed R exactly and its gap test pins the closure). The three NNS.boost cache shards are regenerated from installed R NNS 13.2 at bb878ad (the automation run lost them to the reverted workflow); the sync manifest records that commit. The workflow fixes from #136 (sharded add-paths, shard-aware validation, manifest provenance, per-function cache diff in the PR body, CI dedupe) are restored - they were reverted by the workflow-baseline protection and need the workflow-change-approved label this time. Version bumps to 2.0.0: the threshold argument's meaning changed on an unchanged signature (probability, not score cutoff), and nns_boost output adds class.levels. Known pre-existing local-only failures (unchanged by this PR, skipped in CI): the two Boston-housing live-R NNS.stack practical examples diverge ~0.7 percent from installed R 13.2. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AxBV5HmXHY5nCUvW9SwDvb --- .github/workflows/inspect-r-api-update.yml | 127 +++- .github/workflows/native-backend-ci.yml | 8 + README.md | 2 +- pyproject.toml | 2 +- src/nns/__init__.py | 2 +- src/nns/boost.py | 296 ++++---- sync/nns_source.json | 4 +- .../_r_cache/NNS.boost.factor_predictor.json | 27 +- ....multi_factor_predictor.positional.v1.json | 33 +- tests/_r_cache/NNS.boost.numeric.json | 676 +++++++++++------- tests/invariants/test_boost.py | 36 +- tests/parity/test_boost.py | 10 + tests/parity/test_practical_examples.py | 35 +- 13 files changed, 754 insertions(+), 504 deletions(-) diff --git a/.github/workflows/inspect-r-api-update.yml b/.github/workflows/inspect-r-api-update.yml index d7ffb1f7..f039c9ee 100644 --- a/.github/workflows/inspect-r-api-update.yml +++ b/.github/workflows/inspect-r-api-update.yml @@ -185,24 +185,120 @@ jobs: import os from pathlib import Path - path = Path('tests/_r_cache.json') - if not path.exists(): - raise SystemExit('tests/_r_cache.json was not generated') - payload = json.loads(path.read_text(encoding='utf-8')) - actual = payload.get('nns_version') + cache_dir = Path('tests/_r_cache') + shards = sorted(cache_dir.glob('*.json')) if cache_dir.is_dir() else [] + if not shards: + raise SystemExit('tests/_r_cache/ contains no shard files') expected = os.environ['EXPECTED_VERSION'] - if actual != expected: - raise SystemExit(f'cache version {actual!r} != dispatched version {expected!r}') - entries = payload.get('entries') - if not isinstance(entries, dict) or not entries: - raise SystemExit('regenerated cache contains no entries') - print(f'Validated {len(entries)} cache entries for NNS {actual}.') + total = 0 + for shard_path in shards: + shard = json.loads(shard_path.read_text(encoding='utf-8')) + actual = shard.get('nns_version') + if actual != expected: + raise SystemExit( + f'{shard_path.name}: cache version {actual!r} ' + f'!= dispatched version {expected!r}' + ) + entries = shard.get('entries') + if not isinstance(entries, dict) or not entries: + raise SystemExit(f'{shard_path.name} contains no entries') + total += len(entries) + print( + f'Validated {total} cache entries across {len(shards)} ' + f'shards for NNS {expected}.' + ) PY - name: Remove transient cache files if: always() shell: bash - run: rm -f tests/_r_cache.json.bak tests/_r_cache.lock + run: rm -rf tests/_r_cache.bak && rm -f tests/_r_cache.lock + + - name: Record cache provenance in sync manifest + if: always() + shell: bash + env: + R_COMMIT: ${{ steps.package.outputs.r_commit }} + R_VERSION: ${{ steps.package.outputs.r_version }} + run: | + python - <<'PY' + import json + import os + from pathlib import Path + + path = Path('sync/nns_source.json') + manifest = json.loads(path.read_text(encoding='utf-8')) + manifest['r_commit'] = os.environ['R_COMMIT'] + manifest['r_version'] = os.environ['R_VERSION'] + path.write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8') + print(f"Manifest now records {manifest['r_repo']}@{manifest['r_commit']} " + f"(NNS {manifest['r_version']}).") + PY + + - name: Summarize cache changes by function + id: cachediff + if: always() + shell: bash + run: | + python - <<'PY' + import json + import subprocess + from pathlib import Path + + def committed(name: str) -> dict: + proc = subprocess.run( + ['git', 'show', f'HEAD:tests/_r_cache/{name}'], + capture_output=True, text=True, + ) + if proc.returncode != 0: + return {} + return json.loads(proc.stdout).get('entries', {}) + + cache_dir = Path('tests/_r_cache') + lines = ['### Cache changes by function', ''] + if not cache_dir.is_dir(): + lines.append('_No cache directory was generated; see the regeneration log._') + else: + proc = subprocess.run( + ['git', 'ls-tree', '--name-only', 'HEAD', 'tests/_r_cache/'], + capture_output=True, text=True, + ) + old_names = {Path(p).name for p in proc.stdout.split() if p.endswith('.json')} + new_names = {p.name for p in cache_dir.glob('*.json')} + rows = [] + for name in sorted(old_names | new_names): + before = committed(name) if name in old_names else {} + after = ( + json.loads((cache_dir / name).read_text()).get('entries', {}) + if name in new_names else {} + ) + added = len(set(after) - set(before)) + removed = len(set(before) - set(after)) + changed = sum( + 1 for k in set(before) & set(after) if before[k] != after[k] + ) + if added or removed or changed: + label = name[: -len('.json')] + rows.append( + f'| `{label}` | {len(before)} | {len(after)} ' + f'| {added} | {removed} | {changed} |' + ) + if rows: + lines += [ + '| function | before | after | added | removed | changed |', + '| --- | --- | --- | --- | --- | --- |', + *rows, + ] + else: + lines.append('_No cache entries changed._') + Path('cache-diff.md').write_text('\n'.join(lines) + '\n', encoding='utf-8') + print('\n'.join(lines)) + PY + { + echo 'summary<> "$GITHUB_OUTPUT" - name: Verify committed-cache mode id: verify @@ -246,10 +342,13 @@ jobs: - Live regeneration result: `${{ steps.regenerate.outcome }}` - Cache-only verification result: `${{ steps.verify.outcome }}` - The exact R source package was installed and the committed parity cache was regenerated. Any remaining Python/R parity mismatches are retained in the workflow diagnostics and should be repaired against this R-authored baseline. + The exact R source package was installed and the committed parity cache was regenerated. `sync/nns_source.json` records this R commit as the behavioral-truth provenance. Any remaining Python/R parity mismatches are retained in the workflow diagnostics and should be repaired against this R-authored baseline. + + ${{ steps.cachediff.outputs.summary }} add-paths: | tests/_r.py - tests/_r_cache.json + tests/_r_cache/** + sync/nns_source.json - name: Report parity status if: always() diff --git a/.github/workflows/native-backend-ci.yml b/.github/workflows/native-backend-ci.yml index f5fdd0d0..6d7e3a0b 100644 --- a/.github/workflows/native-backend-ci.yml +++ b/.github/workflows/native-backend-ci.yml @@ -1,12 +1,20 @@ name: Native backend CI +# pull_request covers every branch with an open PR; push is limited to main so +# a PR-branch push does not run the identical matrix twice (once per event). on: pull_request: push: + branches: [main] permissions: contents: read +# A superseded push to the same ref cancels the in-flight run. +concurrency: + group: native-backend-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: native-backend: name: Python ${{ matrix.python-version }} diff --git a/README.md b/README.md index f105f625..0a9bc84e 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ native and does not call R at runtime. |---|---| | Distribution package | `ovvo-nns` | | Import package | `nns` | -| Current version | `1.6.0` | +| Current version | `2.0.0` | | Python | `>=3.11` | | Required runtime dependencies | NumPy, SciPy, Matplotlib | | R required at runtime | No | diff --git a/pyproject.toml b/pyproject.toml index aba2320b..c54523a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ovvo-nns" -version = "1.6.0" +version = "2.0.0" description = "Python port of nonlinear nonparametric statistics from R NNS" readme = "README.md" requires-python = ">=3.11" diff --git a/src/nns/__init__.py b/src/nns/__init__.py index 6b0c9dfe..6dec41a3 100644 --- a/src/nns/__init__.py +++ b/src/nns/__init__.py @@ -4,7 +4,7 @@ from nns.pm_matrix import pm_matrix as pm_matrix -__version__ = "1.6.0" +__version__ = "2.0.0" _EXPORTS = { "BoostResult": ("nns.boost", "BoostResult"), diff --git a/src/nns/boost.py b/src/nns/boost.py index d6e75069..4a71431e 100644 --- a/src/nns/boost.py +++ b/src/nns/boost.py @@ -1,16 +1,17 @@ -"""Repaired NNS.boost port. - -Python port of the audited R/Boost.R (NNS 13.1 Beta tip): learner trials on -feature subsets scored against a holdout, survivor-weighted epoch sampling, -a frequency-weighted synthetic X* final design, local out-of-fold n.best -selection, and strict argument validation. ``depth`` is passed to the -regression engine as ``order``. - -Deviation from R, by design: random subset generation, holdout draws, and -balancing use a local ``numpy.random.Generator`` (``seed``), so randomized -runs match R distributionally rather than bit-for-bit. With ``ts_test`` set -and exhaustive learner trials (2^p - 1 <= learner_trials), the entire run -is deterministic and matches R exactly. +"""NNS.boost port (reconciled R/Boost.R, NNS 13.2). + +Python port of the reconciled R/Boost.R: learner trials fit genuine +multivariate NNS.reg feature subsets against fresh holdouts, the public +``threshold`` is a probability supplied to LPM.VaR over the learner-trial +objective distribution (never a literal score cutoff), epochs re-test only +the surviving combinations (also under exhaustive enumeration), and the +final estimate replicates the original keeper predictors by their relative +epoch frequencies into one multivariate ``nns_stack(method=1, stack=False)`` +call. ``depth`` is passed to the regression engine as ``order``. + +All random draws (holdouts, balancing, subset sampling, epoch scheduling) +replicate R's Mersenne-Twister stream via ``RRNG``, so a seeded run matches +R bit-for-bit. """ from __future__ import annotations @@ -28,6 +29,7 @@ from nns._rrng import RRNG from nns.central_tendencies import nns_gravity from nns.stack import nns_stack +from nns.var import lpm_var Objective = Literal["min", "max"] @@ -89,6 +91,7 @@ def nns_boost( seed: int | None = 123, random_seed: int | None = None, class_levels: list[Any] | None = None, + folds: int = 5, # Passed to the delegated NNS.stack final stage. ncores: int | None = None, ) -> BoostResult: @@ -135,6 +138,7 @@ def nns_boost( learner_trials_value = cast(int, _scalar_integer(learner_trials, "learner.trials", minimum=1)) epochs_value = _scalar_integer(epochs, "epochs", minimum=0, allow_null=True) + folds_value = cast(int, _scalar_integer(folds, "folds", minimum=1)) if cv_size is not None: cv_size = float(cv_size) @@ -349,11 +353,14 @@ def fit_subset( train_y = y[train_index] test_x = x[np.ix_(test_index, cols)] bx, by = balance_training(train_x, train_y) + # Every learner trial fits a genuine multivariate NNS.reg on the + # sampled feature subset (R: dim.red.method = NULL); subsets are never + # collapsed to an equal-weight synthetic X* before scoring. fit = nns_reg_engine( bx, by, point_est=test_x, - dim_red_method="equal" if bx.shape[1] > 1 else None, + dim_red_method=None, order=depth_value, type="CLASS" if is_class else None, point_only=True, @@ -400,99 +407,141 @@ def fit_subset( ) all_index = np.arange(n_obs, dtype=np.int64) - train_index = np.setdiff1d(all_index, validation_index) - actual = y[validation_index] - results = np.full(len(test_features), np.nan) + learner_results = np.full(len(test_features), np.nan) for i, subset in enumerate(test_features): if status: print( f"Current Threshold Iterations Remaining = {len(test_features) - i - 1}", end="\r", ) - predicted = fit_subset(subset, train_index, validation_index) - results[i] = score(predicted, actual) + # Cross-sectional learner trials draw a fresh validation holdout per + # trial (matching the epoch-stage resampling and R's RNG stream); + # time-series trials keep the chronological terminal block. + if ts_test_value is None: + trial_validation_index = random_validation_index() + else: + trial_validation_index = validation_index + trial_train_index = np.setdiff1d(all_index, trial_validation_index) + predicted = fit_subset(subset, trial_train_index, trial_validation_index) + learner_results[i] = score(predicted, y[trial_validation_index]) - finite_results = np.flatnonzero(np.isfinite(results)) + finite_results = np.flatnonzero(np.isfinite(learner_results)) if finite_results.size == 0: raise ValueError("No learner trial produced a finite objective value.") - supplied_threshold = threshold is not None - if not supplied_threshold: - if extreme: - threshold = ( - float(results[finite_results].max()) - if objective_value == "max" - else float(results[finite_results].min()) - ) - else: - threshold = float( - np.quantile( - results[finite_results], - 0.75 if objective_value == "max" else 0.25, - method="averaged_inverted_cdf", - ) - ) + # The public [threshold] argument is a probability supplied to LPM.VaR over + # the learner-trial objective distribution; the objective-score cutoff is + # the distinct value LPM.VaR returns. Neither variable overwrites the other. + threshold_probability = threshold + if threshold_probability is None: + threshold_probability = 0.80 if objective_value == "max" else 0.20 + if extreme: + threshold_probability = 1.0 if objective_value == "max" else 0.0 + learner_threshold = float( + lpm_var(threshold_probability, 1.0, learner_results[finite_results]) + ) + if status: - print(f"\nLearner Accuracy Threshold = {threshold:.4g}") + print( + f"\nLearner threshold probability = {threshold_probability:.2f}; " + f"objective cutoff = {learner_threshold:.6f}" + ) - threshold_f = cast(float, threshold) - passes = np.isfinite(results) & ( - results >= threshold_f if objective_value == "max" else results <= threshold_f + passes = np.isfinite(learner_results) & ( + learner_results >= learner_threshold + if objective_value == "max" + else learner_results <= learner_threshold ) reduced_test_features = [test_features[i] for i in np.flatnonzero(passes)] def best_trial_subset() -> list[tuple[int, ...]]: if objective_value == "min": - idx = finite_results[int(np.argmin(results[finite_results]))] + idx = finite_results[int(np.argmin(learner_results[finite_results]))] else: - idx = finite_results[int(np.argmax(results[finite_results]))] + idx = finite_results[int(np.argmax(learner_results[finite_results]))] return [test_features[int(idx)]] if not reduced_test_features: - if supplied_threshold: - direction = "increase" if objective_value == "min" else "reduce" - raise ValueError(f"No learner subset met [threshold]; {direction} the threshold.") + # Defensive: LPM.VaR returns a value inside the observed range, so the + # best trial always passes; guard anyway for degenerate distributions. reduced_test_features = best_trial_subset() - feature_count = np.zeros(n_features, dtype=np.float64) - for subset in reduced_test_features: - for j in subset: - feature_count[j] += 1 - feature_prob = feature_count + max(1.0, feature_count.sum()) * 1e-12 - feature_prob = feature_prob / feature_prob.sum() - # ---------------------------------------------------------------------- - # Weighted epoch stage + # Epoch stability stage: re-test only the surviving combinations, on a + # fresh holdout per cross-sectional epoch (chronological expanding-window + # blocks for time series). Exhaustive learner trials do not disable this + # stage. RNG draw order mirrors R: survivor permutation first, then the + # time-series split permutation, then one holdout draw per epoch. # ---------------------------------------------------------------------- keeper_features: list[tuple[int, ...]] - if not exhaustive and epochs_value > 0: + if epochs_value > 0: + survivor_count = len(reduced_test_features) + epoch_survivor_id = np.resize( + np.arange(survivor_count, dtype=np.int64), epochs_value + ) + if epoch_survivor_id.size > 1: + epoch_survivor_id = rng.sample( + epoch_survivor_id, epoch_survivor_id.size, replace=False + ) + + chronological_splits: list[tuple[NDArray[np.int64], NDArray[np.int64]]] = [] + epoch_split_id: NDArray[np.int64] | None = None + if ts_test_value is not None: + possible_blocks = (n_obs - 1) // ts_test_value + for b in range(1, possible_blocks + 1): + start = n_obs - b * ts_test_value + validation = np.arange(start, start + ts_test_value, dtype=np.int64) + training = np.arange(0, start, dtype=np.int64) + if training.size >= 3 and has_all_classes(y[training]): + chronological_splits.append((training, validation)) + if not chronological_splits: + raise ValueError( + "No chronological epoch split retained enough training " + "observations and every response class." + ) + epoch_split_id = np.resize( + np.arange(len(chronological_splits), dtype=np.int64), epochs_value + ) + if epoch_split_id.size > 1: + epoch_split_id = rng.sample( + epoch_split_id, epoch_split_id.size, replace=False + ) + keeper_features = [] for j in range(epochs_value): if status: print(f"% of epochs = {(j + 1) / epochs_value:.3f}", end="\r") - k = int(rng.sample_int(n_features, 1)[0]) - subset = tuple( - sorted( - (rng.sample_int_prob_noreplace(n_features, k, feature_prob) - 1).tolist() - ) - ) - predicted = fit_subset(subset, train_index, validation_index) - new_result = score(predicted, actual) + features_j = reduced_test_features[int(epoch_survivor_id[j])] + + if ts_test_value is None: + epoch_validation_index = random_validation_index() + epoch_train_index = np.setdiff1d(all_index, epoch_validation_index) + else: + assert epoch_split_id is not None + epoch_train_index, epoch_validation_index = chronological_splits[ + int(epoch_split_id[j]) + ] + + predicted = fit_subset(features_j, epoch_train_index, epoch_validation_index) + new_result = score(predicted, y[epoch_validation_index]) ok = math.isfinite(new_result) and ( - new_result >= threshold_f + new_result >= learner_threshold if objective_value == "max" - else new_result <= threshold_f + else new_result <= learner_threshold ) if ok: - keeper_features.append(subset) + keeper_features.append(features_j) else: keeper_features = reduced_test_features if not keeper_features: - if supplied_threshold: - direction = "increase" if objective_value == "min" else "reduce" - raise ValueError(f"No epoch subset met [threshold]; {direction} the threshold.") + warnings.warn( + "No feature combination re-passed the objective cutoff during epochs; " + "using the best learner-trial combination. Consider a lower " + "[threshold] probability.", + stacklevel=2, + ) keeper_features = best_trial_subset() counts = np.zeros(n_features, dtype=np.int64) @@ -516,106 +565,58 @@ def best_trial_subset() -> list[tuple[int, ...]]: print("\nGenerating Final Estimate") # ---------------------------------------------------------------------- - # Frequency-weighted synthetic X*. Categorical features are one-hot encoded - # with training levels only; each active dummy inherits its source feature's - # weight so a categorical feature's total row contribution equals its weight. - # ---------------------------------------------------------------------- - def numeric_design( - train_cols: NDArray[Any], test_cols: NDArray[Any] - ) -> tuple[NDArray[np.float64], NDArray[np.float64], list[str]]: - train_blocks: list[NDArray[np.float64]] = [] - test_blocks: list[NDArray[np.float64]] = [] - source: list[str] = [] - for j in range(n_features): - tr = train_cols[:, j] - te = test_cols[:, j] - categorical = tr.dtype.kind in {"U", "S", "O", "b"} and not _column_is_numeric(tr) - if categorical: - tr_str = np.asarray([str(v) for v in tr.tolist()]) - te_str = np.asarray([str(v) for v in te.tolist()]) - levels = sorted(set(tr_str.tolist())) - train_blocks.append( - np.column_stack([(tr_str == lv).astype(np.float64) for lv in levels]) - ) - test_blocks.append( - np.column_stack([(te_str == lv).astype(np.float64) for lv in levels]) - ) - source.extend([feature_names[j]] * len(levels)) - else: - train_blocks.append(np.asarray(tr, dtype=np.float64).reshape(-1, 1)) - test_blocks.append(np.asarray(te, dtype=np.float64).reshape(-1, 1)) - source.append(feature_names[j]) - return np.hstack(train_blocks), np.hstack(test_blocks), source - - design_train, design_test, source_feature = numeric_design(x, z) - if np.any(~np.isfinite(design_train)): - raise ValueError("[IVs.train] contains missing or non-finite values.") - if np.any(~np.isfinite(design_test)): - raise ValueError("[IVs.test] contains missing or non-finite values.") - - tmin = design_train.min(axis=0) - tmax = design_train.max(axis=0) - trange = tmax - tmin - trange = np.where(~np.isfinite(trange) | (trange == 0), 1.0, trange) - train_norm = (design_train - tmin) / trange - test_norm = (design_test - tmin) / trange - - coef_design = np.asarray( - [feature_weights_named.get(src, 0.0) for src in source_feature], dtype=np.float64 - ) - if not np.any(coef_design > 0): - raise ValueError("No positive feature weights were available for the final estimate.") - - xstar_train = train_norm @ coef_design - xstar_test = test_norm @ coef_design - if np.any(~np.isfinite(xstar_train)) or np.any(~np.isfinite(xstar_test)): - raise ValueError("The final synthetic predictor contains non-finite values.") - - def xstar_frame(v: NDArray[np.float64]) -> NDArray[np.float64]: - return np.column_stack((v, v)) - - # ---------------------------------------------------------------------- - # Final estimate via NNS.stack Method 1 on duplicated synthetic X*. - # - # X* is univariate. Duplicating it invokes the multivariate NNS.reg path - # used by NNS.stack so n.best is selected on the regression-point matrix. - # NNS.boost has already performed feature screening and constructed X*, so - # method=(1,) uses only the NNS.reg component and stack=False prevents a - # second dimension-reduction stage. + # Final estimate: replicate the original keeper predictors by their + # relative epoch frequencies and fit one multivariate Method 1 NNS.stack. # - # optimize_threshold=False preserves NNS.boost's existing 0.5 class - # rounding convention. The realized cv_fraction is reused for five - # repeated holdouts; for ts_test, one terminal chronological fold is used. + # The historical scaling rule converts positive keeper-feature counts into + # integer replication factors, so a more stable feature contributes + # proportionally more columns. No synthetic scalar X* is constructed and + # the frequencies never pass through dim_red_method: method=(1,) with + # stack=False keeps the final estimator a multivariate NNS.reg whose + # n.best is selected by NNS.stack's cross-validation. # ---------------------------------------------------------------------- - if status: - print("\nGenerating Final Estimate with NNS.stack Method 1") + frequency_values = np.asarray([plot_table[k] for k in plot_table], dtype=np.float64) + relative_frequency = frequency_values / frequency_values.min() + replication_count = np.maximum(1, np.round(relative_frequency).astype(np.int64)) + + name_to_index = {name: j for j, name in enumerate(feature_names)} + replicated_cols = [ + name_to_index[name] + for name, count in zip(plot_table, replication_count.tolist(), strict=True) + for _ in range(count) + ] + replicated_train = x[:, replicated_cols] + replicated_test = z[:, replicated_cols] final_stack = nns_stack( - xstar_frame(xstar_train), + replicated_train, y, - xstar_frame(xstar_test), + replicated_test, type="CLASS" if is_class else None, obj_fn=obj_fn, objective=objective_value, optimize_threshold=False, dist=dist_value, - cv_size=cv_fraction, - balance=balance, + cv_size=cv_size, + balance=False, ts_test=ts_test_value, - folds=1 if ts_test_value is not None else 5, + folds=folds_value, order=depth_value, method=(1,), stack=False, pred_int=pred_int, status=status, - ncores=ncores, + ncores=1, seed=seed_value, ) + results_raw = final_stack["reg"] + pred_int_output = final_stack["reg.pred.int"] + estimates_code = sanitize_predictions( - np.asarray(final_stack["reg"], dtype=np.float64), y + np.asarray(results_raw, dtype=np.float64), y ) - pred_int_out = final_stack["reg.pred.int"] + pred_int_out = pred_int_output if is_class: n_classes = len(cast(list[Any], class_values)) @@ -647,4 +648,5 @@ def xstar_frame(v: NDArray[np.float64]) -> NDArray[np.float64]: "pred.int": pred_int_out, "feature.weights": feature_weights_named, "feature.frequency": plot_table, + "class.levels": class_values if is_class else None, } diff --git a/sync/nns_source.json b/sync/nns_source.json index e05cd113..ef2489dd 100644 --- a/sync/nns_source.json +++ b/sync/nns_source.json @@ -1,6 +1,6 @@ { "r_repo": "OVVO-Financial/NNS", - "r_commit": "8f236b97ba4612c93c09a949e7e14ec20dcb6c14", + "r_commit": "bb878ad9c3e8282c30eaf47d219a5630db1b1a9d", "r_version": "13.2", "r_src_tree_hash": "1c82fa36a4af3ce6359cc34b3d841afa6bdd1815", "core_repo": "OVVO-Financial/NNS-core", @@ -11,5 +11,5 @@ "vendored_r_path": "tools/NNS", "vendored_r_tarball": "tools/NNS_13.0.tar.gz", "r_cache_path": "tests/_r_cache", - "notes": "NNS-python consumes accepted NNS-core snapshots for native code and verifies public Python behavior against live or cached R NNS. r_commit/r_version record the 13.2 Beta commit that generated the committed parity cache (NNS_13.2.tar.gz). The vendored native snapshot (tools/NNS, r_src_tree_hash, tarball) and NNS-core commit are left at their prior values; re-syncing the native snapshot to this R commit is a separate NNS-core snapshot update and is not part of this cache/behavior reconciliation." + "notes": "NNS-python consumes accepted NNS-core snapshots for native code and verifies public Python behavior against live or cached R NNS. r_commit/r_version record the 13.2 Beta commit carrying the reconciled NNS.boost (LPM.VaR probability threshold, replicated multivariate final stack) that generated the committed parity cache. The vendored native snapshot (tools/NNS, r_src_tree_hash, tarball) and NNS-core commit are left at their prior values; re-syncing the native snapshot is a separate NNS-core snapshot update." } diff --git a/tests/_r_cache/NNS.boost.factor_predictor.json b/tests/_r_cache/NNS.boost.factor_predictor.json index 0927b954..97090805 100644 --- a/tests/_r_cache/NNS.boost.factor_predictor.json +++ b/tests/_r_cache/NNS.boost.factor_predictor.json @@ -1,31 +1,32 @@ { "entries": { "7e2569b9f43465396769eb0f98df139c14d3e3446f4de92ec8c009909d0f2397": { + "class.levels": null, "feature.frequency": [ - 1.0, - 1.0 + 48.0, + 24.0 ], "feature.weights": [ - 0.5, - 0.5 + 0.666666666666667, + 0.333333333333333 ], "pred.int": null, "results": [ - -1.68183104334095, - -1.56097329477908, - -1.56006782103613, - -1.17531960654902, - -1.16837871390595 + -1.75, + -1.48913043478261, + -1.48913043478261, + -1.05434782608696, + -1.05434782608696 ] }, "bce87272f22e3e8a9fcf164a01abb0405342977706e2b714fa18c8451e64eeb4": { "feature.frequency": [ - 1.0, - 1.0 + 48.0, + 24.0 ], "feature.weights": [ - 0.5, - 0.5 + 0.666666666666667, + 0.333333333333333 ] } }, diff --git a/tests/_r_cache/NNS.boost.multi_factor_predictor.positional.v1.json b/tests/_r_cache/NNS.boost.multi_factor_predictor.positional.v1.json index 7334cecc..83103524 100644 --- a/tests/_r_cache/NNS.boost.multi_factor_predictor.positional.v1.json +++ b/tests/_r_cache/NNS.boost.multi_factor_predictor.positional.v1.json @@ -1,34 +1,35 @@ { "entries": { "32b67d32d2361868af0051962aa757ab13d789fb96de6da2bbee7d8669bcacc5": { + "class.levels": null, "feature.frequency": [ - 2.0, - 2.0, - 1.0 + 45.0, + 24.0, + 21.0 ], "feature.weights": [ - 0.4, - 0.4, - 0.2 + 0.5, + 0.266666666666667, + 0.233333333333333 ], "pred.int": null, "results": [ - -1.78183104334095, - -1.66097329477908, - -1.47090145769342, - -1.27531960654902 + -1.85, + -1.58913043478261, + -1.58913043478261, + -1.15434782608696 ] }, "57f7617632ebe2442026868aca75a23af43280a91bc333b5686892fc6d26717e": { "feature.frequency": [ - 2.0, - 2.0, - 1.0 + 45.0, + 24.0, + 21.0 ], "feature.weights": [ - 0.4, - 0.4, - 0.2 + 0.5, + 0.266666666666667, + 0.233333333333333 ] } }, diff --git a/tests/_r_cache/NNS.boost.numeric.json b/tests/_r_cache/NNS.boost.numeric.json index 8ad81a30..99ea82dd 100644 --- a/tests/_r_cache/NNS.boost.numeric.json +++ b/tests/_r_cache/NNS.boost.numeric.json @@ -1,130 +1,160 @@ { "entries": { "0059b5f10871aaacf8c19bba4440f4614911387786fbba23d2954b07a5f41711": { + "class.levels": null, "feature.frequency": [ - 2.0, - 1.0 + 30.0, + 22.0, + 15.0 ], "feature.weights": [ - 0.666666666666667, - 0.333333333333333 + 0.447761194029851, + 0.328358208955224, + 0.223880597014925 ], "pred.int": null, "results": [ - -2.26123535241354, - -2.26425960919816, - -2.26457587225343, - -2.26191104307509, - -2.26230070271705 + -2.75662730808913, + -2.76088032192423, + -2.7593984863087, + -2.5419828526269, + -2.30016095024896 ] }, "080a7bd07c1def100cb81c321234f33a6cfaa42a8198b4f965062e97cd2bc2db": { + "class.levels": null, "feature.frequency": [ - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0 + 4.0, + 4.0, + 4.0, + 4.0, + 3.0, + 3.0, + 2.0, + 2.0, + 2.0, + 2.0 ], "feature.weights": [ - 0.166666666666667, - 0.166666666666667, - 0.166666666666667, - 0.166666666666667, - 0.166666666666667, - 0.166666666666667 + 0.133333333333333, + 0.133333333333333, + 0.133333333333333, + 0.133333333333333, + 0.1, + 0.1, + 0.0666666666666667, + 0.0666666666666667, + 0.0666666666666667, + 0.0666666666666667 ], "pred.int": null, "results": [ - -0.865486586155061, - -1.96648460874029, - -2.56818405000804 + -2.90929742682568, + -2.87037745259433, + -2.8276940887452 ] }, "08a75bf13ae2fbffe3225b18e59993c2ab0ca872714425daaed5dd821e688fc0": { - "feature.frequency": [ - 2.0, + "class.levels": [ 1.0, - 1.0 + 2.0, + 3.0 + ], + "feature.frequency": [ + 36.0, + 36.0, + 24.0 ], "feature.weights": [ - 0.5, - 0.25, + 0.375, + 0.375, 0.25 ], "pred.int": null, "results": [ - 3.0, - 3.0, - 3.0, + 1.0, + 1.0, + 1.0, 1.0, 1.0 ] }, "0903884b4e4aaea1f74314fb7f58a58bb16eb04caaf68d3f1b74b66a78de735e": { + "class.levels": null, "feature.frequency": [ - 2.0, - 1.0 + 40.0, + 30.0, + 25.0 ], "feature.weights": [ - 0.666666666666667, - 0.333333333333333 + 0.421052631578947, + 0.315789473684211, + 0.263157894736842 ], "pred.int": null, "results": [ - -2.21904495390292, - -2.21904495390292, - -2.21904495390292, - -2.21904495390292, - -2.21904495390292 + -2.15767285036657, + -2.15892580631856, + -2.15894336817223, + -2.15943693997468, + -2.15108571409687 ] }, "09fa722adb74513215aafb5b5693b6d916d5723895892f2567d159a91d2223da": { + "class.levels": null, "feature.frequency": [ - 2.0, - 1.0 + 32.0, + 31.0, + 24.0 ], "feature.weights": [ - 0.666666666666667, - 0.333333333333333 + 0.367816091954023, + 0.35632183908046, + 0.275862068965517 ], "pred.int": null, "results": [ - -2.80506109387886, - -2.80407402225758, - -2.58927144060505, - -2.57740816601923, - -2.26549268298996, - -1.96244261714617, - -1.57121457332274, - -1.22625597312261, - -1.21680643992942, - -0.619692137124472, - -0.173906857803296, - 0.028966176300828, - 0.702096189062632, - 0.706844809244688, - 1.17417248924595, - 1.17903570263794, - 1.69854226890176, - 1.97219617711421, - 2.44281848927954, - 2.43836687559355, - 2.44475077196617, - 2.62207559899819, - 2.62706503971369, - 2.62364632685564 + -2.97238120306309, + -2.80773853972378, + -2.61211577204375, + -2.50795241667808, + -2.17172861421595, + -1.90311729414312, + -1.63279821018752, + -1.33405891940224, + -0.837784951122052, + -0.596596535267618, + -0.307554360637907, + 0.21052893327987, + 0.449068510092443, + 0.726421406376522, + 0.953632031472508, + 1.40353879511507, + 1.63308712445153, + 1.97636971027836, + 2.08303428888657, + 2.37206528087544, + 2.55965664828938, + 2.57901602425695, + 2.7500936004042, + 2.78571236119051 ] }, "11fccbafc4fdb564d4ede7572f2a1bea811a31dedd54bbd3845a12220b67edbb": { + "class.levels": [ + "A", + "B", + "C" + ], "feature.frequency": [ - 1.0, - 1.0 + 19.0, + 19.0, + 16.0 ], "feature.weights": [ - 0.5, - 0.5 + 0.351851851851852, + 0.351851851851852, + 0.296296296296296 ], "pred.int": null, "results": [ @@ -140,32 +170,42 @@ }, "2c4e101be1d5ebc88e98cc16e00f12acae4002ac17c2e3119930210cbd4f110b": { "feature.frequency": [ - 2.0, - 1.0 + 26.0, + 19.0, + 17.0 ], "feature.weights": [ - 0.666666666666667, - 0.333333333333333 + 0.419354838709677, + 0.306451612903226, + 0.274193548387097 ] }, "2d7ddbeb93c3522a80f07165bd248ac39ee3e70cdca94490bff1bf4048dae10a": { "feature.frequency": [ - 2.0, - 1.0 + 47.0, + 36.0, + 35.0 ], "feature.weights": [ - 0.666666666666667, - 0.333333333333333 + 0.398305084745763, + 0.305084745762712, + 0.296610169491525 ] }, "2fa1c10be7102ba6b1e161ac0a1989e6d3e35a2615ceac29a8c1535cbed793a9": { - "feature.frequency": [ - 2.0, + "class.levels": [ + 1.0, 2.0 ], + "feature.frequency": [ + 50.0, + 50.0, + 19.0 + ], "feature.weights": [ - 0.5, - 0.5 + 0.420168067226891, + 0.420168067226891, + 0.159663865546218 ], "pred.int": { "pred.int.neg": [ @@ -201,13 +241,19 @@ ] }, "37703591fc7ede242480cd2482ebc48c4946c4f89a35ece73b2504de137fe7a7": { - "feature.frequency": [ - 2.0, + "class.levels": [ + 1.0, 2.0 ], + "feature.frequency": [ + 50.0, + 36.0, + 19.0 + ], "feature.weights": [ - 0.5, - 0.5 + 0.476190476190476, + 0.342857142857143, + 0.180952380952381 ], "pred.int": null, "results": [ @@ -220,13 +266,20 @@ ] }, "3d56fb0f9650d1878459489377f100d0d4f3a58d3b33f51c51117c345f499c6b": { + "class.levels": [ + "A", + "B", + "C" + ], "feature.frequency": [ - 2.0, - 1.0 + 38.0, + 20.0, + 20.0 ], "feature.weights": [ - 0.666666666666667, - 0.333333333333333 + 0.487179487179487, + 0.256410256410256, + 0.256410256410256 ], "pred.int": null, "results": [ @@ -238,30 +291,39 @@ ] }, "4011feb5340fd9a022ffb2c210470a967164f8e6076ca5f05d5ac890a3aa200e": { + "class.levels": null, "feature.frequency": [ - 2.0, - 1.0 + 32.0, + 32.0, + 24.0 ], "feature.weights": [ - 0.666666666666667, - 0.333333333333333 + 0.363636363636364, + 0.363636363636364, + 0.272727272727273 ], "pred.int": null, "results": [ - -2.93507134735831, - -2.93507134735831, - -2.45088165505409, + -3.01333413596247, + -2.85680855875416, + -2.66918653366773, -2.45088165505409 ] }, "4937c6193bd59d88f667bee0c45bc51fb6b4cb45063bb38523820e392369009d": { + "class.levels": [ + 1.0, + 2.0 + ], "feature.frequency": [ - 2.0, - 1.0 + 22.0, + 19.0, + 11.0 ], "feature.weights": [ - 0.666666666666667, - 0.333333333333333 + 0.423076923076923, + 0.365384615384615, + 0.211538461538462 ], "pred.int": null, "results": [ @@ -273,13 +335,17 @@ ] }, "5b92e9f0e7507767068a55611e72073dcaaf281c304312f70fd4d42e5244d0ef": { + "class.levels": [ + 1.0, + 2.0 + ], "feature.frequency": [ - 2.0, - 1.0 + 36.0, + 9.0 ], "feature.weights": [ - 0.666666666666667, - 0.333333333333333 + 0.8, + 0.2 ], "pred.int": null, "results": [ @@ -291,13 +357,17 @@ ] }, "82f49bc64695fbb764c3f8e3a84b849de12fc1a712b23b21eb387745f3c7825f": { + "class.levels": [ + 1.0, + 2.0 + ], "feature.frequency": [ - 2.0, - 1.0 + 36.0, + 9.0 ], "feature.weights": [ - 0.666666666666667, - 0.333333333333333 + 0.8, + 0.2 ], "pred.int": { "pred.int.neg": [ @@ -308,11 +378,11 @@ 1.0 ], "pred.int.pos": [ - 2.0, - 2.0, - 2.0, - 2.0, - 2.0 + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 ] }, "results": [ @@ -324,13 +394,19 @@ ] }, "988678bc86668eed9d46bd9c1819a7b1e7bcfe681556fb9d4a9b83a2f176234c": { + "class.levels": [ + 1.0, + 2.0 + ], "feature.frequency": [ - 2.0, - 1.0 + 22.0, + 19.0, + 11.0 ], "feature.weights": [ - 0.666666666666667, - 0.333333333333333 + 0.423076923076923, + 0.365384615384615, + 0.211538461538462 ], "pred.int": { "pred.int.neg": [ @@ -341,11 +417,11 @@ 1.0 ], "pred.int.pos": [ - 1.0, - 1.0, - 1.0, - 1.0, - 1.0 + 2.0, + 2.0, + 2.0, + 2.0, + 2.0 ] }, "results": [ @@ -358,22 +434,31 @@ }, "9a91d59b7dd3aca887a17aa063d4881f2d32a99d58ee230e5a94ce2192f448c4": { "feature.frequency": [ - 2.0, - 1.0 + 22.0, + 19.0, + 11.0 ], "feature.weights": [ - 0.666666666666667, - 0.333333333333333 + 0.423076923076923, + 0.365384615384615, + 0.211538461538462 ] }, "9f149de2a53a19e40b6956b7ab961c84a7026ba465212331f67d19c42279210d": { - "feature.frequency": [ + "class.levels": [ + 1.0, 2.0, - 1.0 + 3.0 + ], + "feature.frequency": [ + 18.0, + 12.0, + 11.0 ], "feature.weights": [ - 0.666666666666667, - 0.333333333333333 + 0.439024390243902, + 0.292682926829268, + 0.268292682926829 ], "pred.int": null, "results": [ @@ -385,13 +470,19 @@ ] }, "a30b14fc8f398ca5ff4d4545a98267776ac7a0d84c49d37d5bbaee5fc245b613": { - "feature.frequency": [ - 2.0, + "class.levels": [ + 1.0, 2.0 ], + "feature.frequency": [ + 83.0, + 52.0, + 26.0 + ], "feature.weights": [ - 0.5, - 0.5 + 0.515527950310559, + 0.322981366459627, + 0.161490683229814 ], "pred.int": null, "results": [ @@ -408,124 +499,144 @@ ] }, "ac112c05974300862e0d52d062d3a88240a6dc009aedf03bf01c267861c8a09c": { + "class.levels": null, "feature.frequency": [ - 2.0, - 1.0 + 47.0, + 36.0, + 35.0 ], "feature.weights": [ - 0.666666666666667, - 0.333333333333333 + 0.398305084745763, + 0.305084745762712, + 0.296610169491525 ], "pred.int": null, "results": [ - -2.96306052418007, - -2.87441992567514, - -2.87365211050454, - -2.5267989269168, - -2.51763391378746 + -2.90052786551743, + -2.83641272267309, + -2.78557933799801, + -2.56928624781082, + -2.47891743561232 ] }, "b00653a8516b3f68456432b360d687039fd0d8be209d328b42bf93d310e061d7": { + "class.levels": null, "feature.frequency": [ - 2.0, - 1.0 + 22.0, + 20.0, + 10.0 ], "feature.weights": [ - 0.666666666666667, - 0.333333333333333 + 0.423076923076923, + 0.384615384615385, + 0.192307692307692 ], "pred.int": null, "results": [ - -2.66918653366773, + -3.01333413596247, -2.85680855875416, -2.66918653366773, - -2.66918653366773 + -2.45088165505409 ] }, "b026d95ad9afda4d87083558408706d10551461d8b6eed04d26ded0fd1c0d1c9": { + "class.levels": null, "feature.frequency": [ - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0 + 4.0, + 4.0, + 4.0, + 4.0, + 4.0, + 4.0, + 2.0, + 2.0, + 2.0, + 2.0 ], "feature.weights": [ - 0.142857142857143, - 0.142857142857143, - 0.142857142857143, - 0.142857142857143, - 0.142857142857143, - 0.142857142857143, - 0.142857142857143 + 0.125, + 0.125, + 0.125, + 0.125, + 0.125, + 0.125, + 0.0625, + 0.0625, + 0.0625, + 0.0625 ], "pred.int": null, "results": [ - -2.20573778127992, - -2.16879618723278, - -2.20653205202996 + -2.8990427984746, + -2.88063208094542, + -2.81535230609141 ] }, "b63e47fae9b9f60d20024fbf1645b8b74b7187507a07a94b82840ae43afbcc99": { + "class.levels": null, "feature.frequency": [ - 2.0, - 1.0 + 41.0, + 40.0, + 29.0 ], "feature.weights": [ - 0.666666666666667, - 0.333333333333333 + 0.372727272727273, + 0.363636363636364, + 0.263636363636364 ], "pred.int": { "pred.int.neg": [ - 1.5324271848969, - 1.53893982728078, - 1.54552563399527, - 1.54544495160328, - 1.54484799981748, - 1.54457407366544, - 1.54448009053284, - 1.54448018698745, - 1.54452421697136, - 1.5445840117499 + 1.91107047285419, + 2.0775894332811, + 2.07679254404724, + 2.36534161263055, + 2.36255805209853, + 2.44633438364903, + 2.63221619783694, + 2.62955016991958, + 2.62974922809057, + 2.62768452312257 ], "pred.int.pos": [ - 2.82604846649525, - 2.83256110887912, - 2.83914691559361, - 2.83906623320163, - 2.83846928141582, - 2.83819535526378, - 2.83810137213119, - 2.8381014685858, - 2.8381454985697, - 2.83820529334825 + 2.32410673882482, + 2.49062569925173, + 2.48982881001787, + 2.77837787860118, + 2.77559431806915, + 2.85937064961966, + 3.04525246380757, + 3.0425864358902, + 3.0427854940612, + 3.0407207890932 ] }, "results": [ - 1.85030074727348, - 1.85681338965736, - 1.86339919637185, - 1.86331851397987, - 1.86272156219406, - 1.86244763604202, - 1.86235365290942, - 1.86235374936404, - 1.86239777934794, - 1.86245757412649 + 2.10736788369373, + 2.27388684412064, + 2.27308995488678, + 2.56163902347009, + 2.55885546293807, + 2.64263179448857, + 2.82851360867648, + 2.82584758075912, + 2.82604663893011, + 2.82398193396212 ] }, "cb1a5fa2cc6c1099f249b1a4459c168c49e44b4d8040b32cbc3e04a8ec8a4d70": { + "class.levels": [ + 1.0, + 2.0 + ], "feature.frequency": [ - 3.0, - 2.0, - 1.0 + 80.0, + 50.0, + 46.0 ], "feature.weights": [ - 0.5, - 0.333333333333333, - 0.166666666666667 + 0.454545454545455, + 0.284090909090909, + 0.261363636363636 ], "pred.int": null, "results": [ @@ -542,13 +653,19 @@ ] }, "cb2ded71ad39d2a9f6272aef3e744a445f3598f0ebe81f4fcb5f7f43e277332d": { + "class.levels": [ + 1.0, + 2.0 + ], "feature.frequency": [ - 2.0, - 1.0 + 37.0, + 14.0, + 10.0 ], "feature.weights": [ - 0.666666666666667, - 0.333333333333333 + 0.60655737704918, + 0.229508196721311, + 0.163934426229508 ], "pred.int": null, "results": [ @@ -561,105 +678,118 @@ }, "d43542d9ed8dcd18f9623355d94839bcf1bda02d8cf9672a7574009e80c14992": { "feature.frequency": [ - 2.0, - 1.0 + 22.0, + 19.0, + 11.0 ], "feature.weights": [ - 0.666666666666667, - 0.333333333333333 + 0.423076923076923, + 0.365384615384615, + 0.211538461538462 ] }, "e1b3a4917c8cada43d26e62ed3ad7c66691b80a9d4f0187519fb3c69dad94c51": { "feature.frequency": [ - 2.0, - 1.0 + 47.0, + 36.0, + 35.0 ], "feature.weights": [ - 0.666666666666667, - 0.333333333333333 + 0.398305084745763, + 0.305084745762712, + 0.296610169491525 ] }, "e40a2aad2a9f4e334b66fabdab83765664c53aa07799c3bb1adfa5e4888690db": { + "class.levels": null, "feature.frequency": [ - 3.0, - 3.0, - 1.0 + 17.0, + 16.0, + 13.0, + 11.0 ], "feature.weights": [ - 0.428571428571429, - 0.428571428571429, - 0.142857142857143 + 0.298245614035088, + 0.280701754385965, + 0.228070175438596, + 0.192982456140351 ], "pred.int": null, "results": [ - -1.13012389637901, - -1.06246769892354, - -1.06196023265862, - -0.842822156379699 + -1.12863312300686, + -1.062230857296, + -0.967585654893234, + -0.878418252930033 ] }, "f854d1524c0b0d5c55beae24a9cb9a9681e0beedd4b975d853cb328cc20f96c0": { + "class.levels": null, "feature.frequency": [ - 2.0, - 1.0 + 51.0, + 39.0, + 31.0 ], "feature.weights": [ - 0.666666666666667, - 0.333333333333333 + 0.421487603305785, + 0.322314049586777, + 0.256198347107438 ], "pred.int": { "pred.int.neg": [ - 0.647403873972098, - 0.647403873972098, - 0.647403873972098, - 0.647403873972098, - 0.647403873972098, - 0.647403873972098, - 0.647403873972098, - 0.647403873972098, - 0.647403873972098, - 0.647403873972098 + 1.99290939265377, + 1.99191996775596, + 1.98894987556241, + 1.99270161295097, + 1.99856345477696, + 2.00425375160301, + 2.00577318349238, + 2.00527991632752, + 2.00394937477257, + 2.00228775992709 ], "pred.int.pos": [ - 3.10979513697027, - 3.10979513697027, - 3.10979513697027, - 3.10979513697027, - 3.10979513697027, - 3.10979513697027, - 3.10979513697027, - 3.10979513697027, - 3.10979513697027, - 3.10979513697027 + 3.09984905129141, + 3.0988596263936, + 3.09588953420005, + 3.09964127158861, + 3.10550311341461, + 3.11119341024065, + 3.11271284213002, + 3.11221957496517, + 3.11088903341021, + 3.10922741856473 ] }, "results": [ - 1.77760309718131, - 1.77760309718131, - 1.77760309718131, - 1.77760309718131, - 1.77760309718131, - 1.77760309718131, - 1.77760309718131, - 1.77760309718131, - 1.77760309718131, - 1.77760309718131 + 2.36012747088073, + 2.35913804598292, + 2.35616795378937, + 2.35991969117793, + 2.36578153300393, + 2.37147182982997, + 2.37299126171934, + 2.37249799455449, + 2.37116745299953, + 2.36950583815405 ] }, "f96252eaf87acb3fc88526193903e18143dda6431cfcd7fe8f31cf255e1c0278": { + "class.levels": null, "feature.frequency": [ - 2.0, - 1.0 + 26.0, + 19.0, + 17.0 ], "feature.weights": [ - 0.666666666666667, - 0.333333333333333 + 0.419354838709677, + 0.306451612903226, + 0.274193548387097 ], "pred.int": null, "results": [ - -2.93507134735831, - -2.93507134735831, - -2.45088165505409, + -3.01333413596247, + -2.85680855875416, + -2.66918653366773, -2.45088165505409 ] } diff --git a/tests/invariants/test_boost.py b/tests/invariants/test_boost.py index 9778da81..0000310d 100644 --- a/tests/invariants/test_boost.py +++ b/tests/invariants/test_boost.py @@ -86,7 +86,13 @@ def test_nns_boost_multiple_factor_predictors_are_positional(features_only: bool assert set(result) == ( {"feature.weights", "feature.frequency"} if features_only - else {"results", "pred.int", "feature.weights", "feature.frequency"} + else { + "results", + "pred.int", + "feature.weights", + "feature.frequency", + "class.levels", + } ) assert sum(result["feature.weights"].values()) == pytest.approx(1.0) if not features_only: @@ -206,21 +212,27 @@ def test_nns_boost_stochastic_epoch_path_pred_int_shape() -> None: def test_nns_boost_threshold_on_stochastic_epoch_path_matches_r() -> None: - # The stochastic epoch path now honors [threshold] as R does: an - # unreachable threshold raises the "no subset met threshold" error. + # [threshold] is a probability supplied to LPM.VaR over the learner-trial + # objective distribution, as in R. threshold=1.0 selects the extreme + # objective cutoff; the best trial always survives, so the run completes + # instead of raising the old literal-cutoff error. x = np.linspace(-2.0, 2.0, 20) variable = np.column_stack([np.sin((idx + 1) * x) for idx in range(11)]) y = x + np.sin(x) - with pytest.raises(ValueError, match="threshold"): - nns_boost( - variable, - y, - variable[:3], - cv_size=0.25, - threshold=1.0, - feature_importance=False, - ) + result = nns_boost( + variable, + y, + variable[:3], + cv_size=0.25, + threshold=1.0, + epochs=5, + learner_trials=5, + feature_importance=False, + ) + + assert result["results"].shape == (3,) + assert np.all(np.isfinite(result["results"])) @pytest.mark.stochastic diff --git a/tests/parity/test_boost.py b/tests/parity/test_boost.py index e9e2a48f..efb68f33 100644 --- a/tests/parity/test_boost.py +++ b/tests/parity/test_boost.py @@ -965,6 +965,16 @@ def _assert_nested_numeric_close(actual: Any, expected: Any) -> None: for key in actual: _assert_nested_numeric_close(actual[key], expected[key]) return + # Character components (e.g. class.levels for factor responses) compare as + # exact string sequences. + actual_list = actual.tolist() if isinstance(actual, np.ndarray) else actual + if isinstance(actual_list, list) and any(isinstance(v, str) for v in actual_list): + expected_list = expected.tolist() if isinstance(expected, np.ndarray) else expected + assert [str(v) for v in actual_list] == [str(v) for v in expected_list] + return + if isinstance(actual, str) or isinstance(expected, str): + assert str(actual) == str(expected) + return np.testing.assert_allclose( np.asarray(actual, dtype=np.float64), np.asarray(expected, dtype=np.float64), diff --git a/tests/parity/test_practical_examples.py b/tests/parity/test_practical_examples.py index 80684e8d..fa5b8aac 100644 --- a/tests/parity/test_practical_examples.py +++ b/tests/parity/test_practical_examples.py @@ -209,24 +209,13 @@ def test_iris_stack_classification_vignette_predicts_holdout_class() -> None: @pytest.mark.parity @pytest.mark.practical -@pytest.mark.xfail( - reason=( - "Verified R sampling edge in the balanced NNS.boost path. Reference: " - "R 4.3.3, NNS 13.1, RNGkind() = c('Mersenne-Twister','Inversion'," - "'Rejection') (sample.kind = 'Rejection'), set.seed(123) with NNS.boost's " - "own internal seed = 123L, iris factor levels ordered " - "setosa < versicolor < virginica. NNS Python reproduces R's Mersenne-" - "Twister stream and Rejection sample.int bit-for-bit on every " - "deterministic and single-draw boost path (see the strict parity tests " - "in test_boost.py), but balance = TRUE issues many interleaved per-class " - "sample() draws (down-sample without replacement + up-sample with " - "replacement, for every learner trial and epoch), and that exact draw " - "ordering is not reproduced step-for-step. R predicts the all-class-3 " - "holdout as 3s; the port's split lands one holdout on class 2." - ), - strict=True, -) def test_iris_boost_classification_vignette_matches_installed_r_diagnostics() -> None: + # Historically a strict xfail: the pre-13.2 balanced NNS.boost path issued + # interleaved per-class sample() draws the port did not reproduce + # step-for-step. The reconciled 13.2 flow (fresh holdout per learner trial, + # survivor-scheduled epochs, replicated multivariate final stack) is + # replicated draw-for-draw via RRNG, so the balanced vignette now matches + # installed R bit-for-bit. expected = _r_iris_classification_vignette() actual = _iris_boost_diagnostics(expected) @@ -235,18 +224,16 @@ def test_iris_boost_classification_vignette_matches_installed_r_diagnostics() -> @pytest.mark.parity @pytest.mark.practical -def test_iris_boost_classification_vignette_gap_is_explicit() -> None: +def test_iris_boost_classification_vignette_gap_is_closed() -> None: expected = _r_iris_classification_vignette() actual = _iris_boost_diagnostics(expected) expected_boost = cast(dict[str, object], expected["boost"]) y_test = _array(expected["y_test"]) - # The port's balanced-boost split diverges from R's (see the xfail above): - # R nails the all-class-3 holdout exactly, while the port lands one holdout - # observation on class 2. This test pins that explicit, characterized gap. - assert not np.array_equal(_array(actual["results"]), _array(expected_boost["results"])) - assert not np.array_equal(_array(actual["results"]), y_test) - # R reproduces the vignette's holdout perfectly under seed 123. + # The formerly characterized balanced-boost sampling gap is closed: the + # port reproduces R's holdout predictions exactly, and R reproduces the + # vignette's holdout perfectly under seed 123. + assert np.array_equal(_array(actual["results"]), _array(expected_boost["results"])) assert np.array_equal(_array(expected_boost["results"]), y_test) assert set(actual) == {"results", "feature_weights", "feature_frequency"}