Skip to content

fix: keep degree-1 and degree-2 fits finite and positive on tied, gappy data - #42

Merged
tnagler merged 1 commit into
mainfrom
fix/dpik-empty-bins
Sep 30, 2026
Merged

tnagler merged 1 commit into
mainfrom
fix/dpik-empty-bins

Conversation

@tvatter

@tvatter tvatter commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

On a column with many ties and a wide gap, Kde1d could return a density of exactly zero inside the data range, so a held-out observation there had log-likelihood -inf. The bandwidth chosen for that column also depended on the build. The column comes from a density benchmark: 1023 observations, 75 distinct values, a clump of ties at a cap, and no observations for about ten bandwidths below it.

Changes

  • Bandwidth selection (dpik.hpp). ll_ibias2 now sums only over occupied bins. The pilot density is zero up to FFT round-off in empty bins far from the data, and 0 * inf there made the bias term NaN on some builds and finite on others. A NaN falls back to the normal-reference rule, so on that column one build selected 0.710 and another 0.2245, the plug-in value. Every build now selects 0.2245.
  • Degree 2 (fit_lp). The Hjort–Jones local quadratic exists only where 1 + h²D > 0. That condition fails next to a clump of ties, and the resulting NaN from sqrt used to be written out as a density of zero. At those points the local linear fit is used instead, together with its own influence value.
  • Degree 1 (fit_lp). The local linear estimate is f·exp(-h²b²/2), but the code used h where it should use h², so the estimate was not scale equivariant. On one standard-normal sample rescaled by 0.1, 1 and 10, the density at 1.5 was 0.066, 0.148 and 0.155. It is now 0.1538 at every scale. This changes every degree-1 fit.
  • Positivity (fit). The binned kernel is truncated at 4h, so a point in a wide enough gap still got exactly zero at any degree. Grid values between the smallest and largest observation are now floored at eps × max. The interpolating spline preserves positivity (Schmidt–Hess), so the density is positive across the whole data range.

Tests

  • Unbounded fits are scale equivariant at every degree. The existing scale tests cover only bounded fits, where the transform hid the degree-1 error.
  • The density stays positive across a synthetic gap of 20 bandwidths, at every degree.
  • The benchmark column, stored as test/data/tied_gap_sample.hpp, selects the plug-in bandwidth and has positive density and finite loglik and edf at every degree.

All three fail on main: 7 failed assertions. All pass with this change.

Downstream, pyvinecopulib's kde1d suites (test_kde1d, test_torch_kde1d, test_margins, test_sklearn_margins, test_plots; 234 tests) pass against this branch. Across 1,500 benchmark columns, 11 bandwidths change, all from the NaN fallback to the plug-in value, and no held-out point has density zero any more (previously one).

🤖 Generated with Claude Code

…py data

Three defects, found on one column of a density-estimation benchmark (1023
observations, 75 distinct values, a clump of ties at a cap, and a gap of about
ten bandwidths below it). A point in the gap got a density of exactly zero, so
any held-out observation there had log-likelihood -inf.

Bandwidth selection. `ll_ibias2` averages the bias functional over all bins,
weighted by their counts. At degree 1 and 2 the functional divides by the pilot
density, which is zero up to FFT round-off in empty bins far from the data, so
`0 * inf` or `0 * NaN` made the sum NaN on some builds and finite on others.
`select_bandwidth` then took the normal-reference fallback (0.710) on the
first and the plug-in value (0.2245) on the second. Only occupied bins
contribute now, so every build selects the plug-in value.

The degree-2 fit. The local quadratic of Hjort and Jones exists only where
`1 + h^2 D > 0`, with `D` the second derivative of the pilot log-density. Next
to a clump of ties the pilot log-density is as curved as the kernel's own and
the condition fails. `sqrt` of a negative number then gave NaN, which `fit_lp`
wrote as a density of zero. Where the local quadratic does not exist or is not
finite, the local linear fit now stands in, with its own influence value for
the degrees of freedom.

The degree-1 fit. The local linear estimate is `f * exp(-h^2 b^2 / 2)` with
`b = f' / f`, but `fit_lp` used `h` for `h^2`. The estimate was therefore not
scale equivariant: on the same standard-normal sample rescaled by 0.1, 1 and
10, the density at 1.5 read 0.066, 0.148 and 0.155. It reads 0.1538 at every
scale now. Every degree-1 fit changes, bounded ones included: those are
fitted on the transformed scale, where the same `h` for `h^2` applies.

Positivity. The binned kernel is truncated at four bandwidths, so a point in a
wide enough gap still had density zero at any degree. The grid values between
the smallest and largest observation are floored at machine epsilon times the
largest value. The interpolating spline preserves positivity, so the density
is positive everywhere in that range.

Tests: scale equivariance of unbounded fits at every degree (the existing
tests covered only bounded fits, where the transform hid the degree-1 error),
positivity across a synthetic gap of twenty bandwidths, and the benchmark
column as test data, pinning the plug-in bandwidth and positivity at every
degree. All three fail before this change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tvatter added a commit to vinecopulib/pyvinecopulib that referenced this pull request Sep 24, 2026
Pins lib/kde1d at the head of vinecopulib/kde1d-cpp#42, which fixes three
defects found on a benchmark column with 75 distinct values in 1023 rows, a
clump of ties at a cap, and a gap of about ten bandwidths:

- the degree-2 plug-in bandwidth summed 0 * inf over empty bins, so it came
  out NaN on some builds and took the normal-reference fallback there only
  (0.710 against the plug-in 0.2245 on that column);
- the degree-2 local quadratic does not exist where 1 + h^2 D <= 0, as next to
  a clump of ties, and the resulting NaN was written out as a density of
  zero; the local linear fit stands in there now;
- the degree-1 correction used h for h^2, so a degree-1 fit was not scale
  equivariant.

Grid values between the smallest and largest observation are also floored at
machine epsilon times the largest, because the binned kernel is truncated at
four bandwidths and a point in a wide enough gap got density zero at any
degree. A held-out observation there had log-likelihood -inf.

Across 1,500 benchmark columns, 11 bandwidths move, each from the fallback to
the plug-in value, and no held-out point has density zero.

The pin is a branch head; re-pin to the merged commit and its v1.2.3 tag
before this merges.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@tnagler
tnagler merged commit cf3fd7f into main Sep 30, 2026
9 checks passed
@tnagler
tnagler deleted the fix/dpik-empty-bins branch September 30, 2026 08:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants