Source code for knowledgespaces.estimation.observation_masks
"""Empirical independent observation masks and their explicitly scoped Pearson statistic."""
from __future__ import annotations
import numpy as np
from scipy.special import logsumexp
from knowledgespaces._patterns import _positive_integer
from knowledgespaces.estimation.incomplete import IncompleteBLIMEstimate, IncompleteResponseMatrix
def _empirical_mask_distribution(data: IncompleteResponseMatrix) -> tuple[np.ndarray, np.ndarray]:
masks, inverse = np.unique(data.observed, axis=0, return_inverse=True)
weights = np.bincount(inverse, weights=data.effective_counts)
keep = weights > 0
return masks[keep], weights[keep] / weights.sum()
[docs]
def sample_observation_masks(
data: IncompleteResponseMatrix,
n_respondents: int,
*,
seed: int | np.random.Generator | None = None,
max_memory_bytes: int = 512_000_000,
) -> np.ndarray:
"""Sample entire observed masks independently from their weighted empirical law.
True means observed. Columns follow data.items; rows are newly sampled
individuals. Counts weight masks even for aggregated response patterns;
repeated masks pool their weights, zero-weight masks are never sampled.
Fractional nonnegative source weights are allowed: these specify a
discrete sampling distribution, not a fractional simulated sample size.
Joint item missingness is retained; masks are not sampled cell by cell.
Applying these draws independently of complete responses models MCAR,
not generic MAR. This function does not infer a missingness mechanism.
"""
n_respondents = _positive_integer(n_respondents, "n_respondents")
max_memory_bytes = _positive_integer(max_memory_bytes, "max_memory_bytes")
if (
8 * (data.n_patterns * (data.n_items + 4) + n_respondents * (data.n_items + 1))
> max_memory_bytes
):
raise MemoryError("Observation mask sampling exceeds max_memory_bytes.")
masks, probabilities = _empirical_mask_distribution(data)
rng = np.random.default_rng(seed)
result = masks[rng.choice(len(masks), n_respondents, p=probabilities)]
result.flags.writeable = False
return result
[docs]
def independent_mask_pearson(
estimate: IncompleteBLIMEstimate,
data: IncompleteResponseMatrix,
*,
max_memory_bytes: int = 512_000_000,
) -> float:
"""Pearson X2 for an explicitly independent empirical-mask response model.
For each observed mask O, fit its mass as N_O/N. Joint cell probabilities
are P(O) P_theta(R_O), giving X2 = sum_r n_r^2 / [N_O(r) P_theta(R_O)] - N.
Sum over unique value-and-mask patterns; unseen cells are included by
the normalization identity. This also equals the sum of conditional
Pearson statistics across fixed exogenous mask strata, with sizes N_O.
This factorization requires independent masks (MCAR) or an exogenous
fixed design. Ignorable MAR alone does NOT justify it. No automatic
degrees of freedom or chi-squared reference distribution is supplied.
All-missing strata contribute zero. Fractional weights produce only a
descriptive statistic; respondent resampling requires integer counts.
Impossible observed cells yield infinity. Columns align by item label.
"""
max_memory_bytes = _positive_integer(max_memory_bytes, "max_memory_bytes")
if 8 * data.n_patterns * (data.n_items + 6) > max_memory_bytes:
raise MemoryError("Independent-mask Pearson exceeds max_memory_bytes.")
aggregated = data.aggregate()
_, inverse = np.unique(aggregated.observed, axis=0, return_inverse=True)
counts = aggregated.effective_counts
mask_counts = np.bincount(inverse, weights=counts)
log_probability = estimate.predict(
aggregated.patterns,
items=aggregated.items,
max_memory_bytes=max_memory_bytes,
).log_probabilities
log_sum = logsumexp(2 * np.log(counts) - np.log(mask_counts[inverse]) - log_probability)
# expm1 avoids subtracting nearly equal O(N) numbers for a fitted table.
with np.errstate(over="ignore"):
result = aggregated.n_respondents * np.expm1(log_sum - np.log(aggregated.n_respondents))
return max(0.0, float(result))