Skip to content

Apply missing_data='exclude' to every statistic in the fused engines - #332

Merged
nspope merged 2 commits into
mainfrom
fix/windowed-exclude-diploshic
Oct 19, 2026
Merged

nspope merged 2 commits into
mainfrom
fix/windowed-exclude-diploshic

Conversation

@andrewkern

@andrewkern andrewkern commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Fixes #330.

Problem

Under missing_data='exclude', windowed_analysis sent only Garud-only requests to the fused engine. Every other request went to the per-window fallback, which does not know these statistics, so the call raised Unknown statistic:

Called directly, windowed_statistics_fused and windowed_statistics_fused_chunked silently ignored 'exclude' for every statistic except the Garud family and omega. The values and n_variants were the same as under 'include'.

Changes

  • Pre-filter in both fused engines. Under 'exclude', both engines restrict the matrix to its complete sites before they index windows (_apply_exclude).
    • A site's completeness does not depend on the window, so this equals dropping each window's incomplete sites. The window grid comes from the bin edges and does not move.
    • Completeness is judged over the rows the request reads: both populations when a two-population statistic is requested, else the single population, else every row.
    • After the filter the engine runs in 'include' mode. So the chunked engine's hand-off to the unchunked engine does not filter again over a different set of rows.
    • A Garud-only request skips the filter, because garud_h_windows drops incomplete columns itself without a copy. That path, including its n_variants, is unchanged.
  • Routing. Under 'exclude', windowed_analysis uses the fused engine for any request that contains a statistic the fallback cannot compute. The set comes from the fallback's own statistic tables (_fallback_stats), not from a separate list. Every request that this routes differently raised on main; requests that worked on main keep their engine.
  • Cheaper mask. exclude_missing_sites finds incomplete columns with column_has_missing: one pass and one byte per site, with no matrix-sized boolean.
  • One constant. The five copies of the two-population statistic names in windowed_analysis.py now share _TWO_POP_STATS.

Known limits

Tests

tests/test_windowed_exclude.py, 25 tests:

  • Each affected statistic under 'exclude' against the scalar function on the window's complete sites, with n_variants and the window starts.
  • A mixed request against the single-statistic requests, and pi against the complete sites.
  • daf_hist against diversity.daf_histogram.
  • theta_h, singletons, fay_wu_h and max_daf with a Garud statistic and with two populations.
  • Both fused engines called directly, against 'include' on the complete sites.
  • The chunked and unchunked engines agree when some rows belong to neither population. This test fails if the hand-off filters a second time.
  • Completeness judged within one population and within two.
  • The grid does not move when an end site is incomplete.
  • The streaming path against the scalar functions.

24 of the 25 tests fail on main. The chunked-against-unchunked test passes on main only because both engines ignore 'exclude' there.

The full suite passes: 1957 passed, 81 skipped, 15 xfailed. ruff check pg_gpu/ tests/ examples/ passes.

Under 'exclude', windowed_analysis sent only Garud-only requests to the
fused engine. Every other request went to the per-window fallback,
which does not know zns, omega, mu_ld, mu_var, mu_sfs, daf_hist, the
snp_dist_* and dist_* statistics, or mean_nsl, so those requests raised
"Unknown statistic". theta_h, singletons, fay_wu_h and max_daf raised
the same way when requested with a Garud statistic, or with two
populations and no two-population statistic. Called directly,
windowed_statistics_fused and windowed_statistics_fused_chunked
silently ignored 'exclude' for every statistic except the Garud family
and omega.

Both fused engines now restrict the matrix to its complete sites before
they index windows. A site's completeness does not depend on the
window, so this equals dropping each window's incomplete sites, and the
window grid, which comes from the bin edges, does not move.
Completeness is judged over the rows the request reads: both
populations when a two-population statistic is requested, else the
single population, else every row. After the filter the engines run in
'include' mode, so the chunked engine's hand-off to the unchunked one
does not filter again over a different set of rows. A Garud-only
request skips the filter, because garud_h_windows drops incomplete
columns itself without a copy.

Under 'exclude', windowed_analysis now uses the fused engine for any
request that contains a statistic the fallback cannot compute. That set
comes from the fallback's own statistic tables, not from a separate
list. Every request that the change routes differently raised on main.

exclude_missing_sites finds incomplete columns with column_has_missing
(one pass, one byte per site) instead of a matrix-sized boolean. The
filter still copies the complete sites, so under 'exclude' an engine
holds one extra copy of the matrix next to the caller's.

The five copies of the two-population statistic names in
windowed_analysis.py now share one constant.

Fixes #330

@nspope nspope left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good! couple minor points

Otherwise the filter copies the complete sites, so under 'exclude' an
engine holds one extra copy of the matrix next to the caller's.
"""
if missing_data != 'exclude' or set(statistics) <= set(_FUSED_GARUD_STATS):

@nspope nspope Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm ... so if statistics contains something that's not a Garud stat, then this doesn't fire, and the call goes through to the exclude_missing_sites below? That doesn't seem quite right?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for example,

import numpy as np
from pg_gpu import HaplotypeMatrix
from pg_gpu.windowed_analysis import windowed_analysis

N_HAP, N_VAR, SPACING, WIN = 40, 40, 10, 200
rng = np.random.default_rng(0)
hap = (rng.random((N_HAP, N_VAR)) < 0.3).astype(np.int8)
hap[:, 5] = np.where(np.arange(N_HAP) < 20, hap[:, 5], -1)  # site 5 missing in rows 20-39 only
pos = np.arange(N_VAR, dtype=np.int64) * SPACING
hm = HaplotypeMatrix(hap.copy(), pos)
df_garud_only = windowed_analysis(
    hm, window_size=WIN, step_size=WIN, 
    statistics=["garud_h1"],
    missing_data="exclude",
)
df_garud_plus = windowed_analysis(
    hm, window_size=WIN, step_size=WIN, 
    statistics=["garud_h1", "zns"],
    missing_data="exclude",
)
print("n_variants garud-only:", df_garud_only["n_variants"].to_numpy())
print("n_variants garud+zns: ", df_garud_plus["n_variants"].to_numpy())
n_variants garud-only: [20 20]
n_variants garud+zns:  [19 20]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah-- you are right. fixed this so the fused engine now counts the complete sites when it builds the window columns and the fallback sets the complete count on the window, so the n_variants column and the n_variants statistic agree.

with the latest commit your example gives

n_variants garud-only: [19 20]                                                                                                   │
n_variants garud+zns:  [19 20]

if haplotype_matrix.device == 'CPU':
haplotype_matrix.transfer_to_gpu()

haplotype_matrix, missing_data = _apply_exclude(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is happening before the pop1/pop2 parameter check, so we don't raise the right error if pops aren't supplied to a raw call of the engine:

import numpy as np
from pg_gpu import HaplotypeMatrix
from pg_gpu.windowed_analysis import windowed_statistics_fused

hap = np.zeros((40, 40), dtype=np.int8)
hm = HaplotypeMatrix(hap, np.arange(40, dtype=np.int64) * 10)
edges = np.arange(0, 401, 200)
windowed_statistics_fused(hm, bp_bins=edges, statistics=("fst",), missing_data="exclude")
# TypeError: 'NoneType' object is not iterable

(should raise the value error indicating pops are required for Fst)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Fixed this so pop1/pop2 check now runs at the start of _apply_exclude before the filter

Under missing_data='exclude', a Garud-only request and the per-window
fallback reported n_variants as every site in the window, while the
other fused requests and the scatter engine counted the complete sites
only. A Garud-only request skips the filtered copy, so each fused engine
now counts its complete sites when it builds the window columns. The
fallback sets the complete count on the window, so the n_variants
column and the n_variants statistic read the same value.

The fused engines checked pop1 and pop2 after the exclude filter, which
raised TypeError when they were missing. The check now runs first, in
_apply_exclude, and raises the same ValueError as under 'include'.
@nspope
nspope merged commit 956d931 into main Oct 19, 2026
1 check passed
@nspope

nspope commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

Looks good, merging

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.

windowed_analysis rejects the diploS/HIC statistics and mean_nsl under missing_data='exclude'

2 participants