Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/fast-mlsirm-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9429,6 +9429,7 @@ fn residual_interaction_map(
out.set_item("residual", result.residual)?;
out.set_item("distance", result.distance)?;
out.set_item("reconstruction", result.reconstruction)?;
out.set_item("explained_share", result.explained_share)?;
out.set_item("unexplained", result.unexplained)?;
out.set_item("cross_share", result.cross_share)?;
out.set_item("axis_count", result.axis_count)?;
Expand Down
20 changes: 20 additions & 0 deletions crates/mlsirm-core/src/interaction_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ pub struct ResidualInteractionMap {
pub residual: Vec<f64>,
pub distance: Vec<f64>,
pub reconstruction: Vec<f64>,
pub explained_share: Vec<Option<f64>>,
pub unexplained: Vec<f64>,
pub cross_share: Vec<Option<f64>>,
pub axis_count: usize,
Expand Down Expand Up @@ -137,6 +138,7 @@ pub fn residual_interaction_map(
residual: Vec::new(),
distance: Vec::new(),
reconstruction: Vec::new(),
explained_share: Vec::new(),
unexplained: Vec::new(),
cross_share: Vec::new(),
axis_count,
Expand Down Expand Up @@ -200,6 +202,7 @@ pub fn residual_interaction_map(
})
.collect();
let mut reconstruction = Vec::with_capacity(rows * columns);
let mut explained_share = Vec::with_capacity(rows * columns);
let mut distance = Vec::with_capacity(rows * columns);
let mut unexplained = Vec::with_capacity(rows * columns);
let mut cross_share = Vec::with_capacity(rows * columns);
Expand All @@ -223,6 +226,13 @@ pub fn residual_interaction_map(
.sum::<f64>();
let raw = residual[person * columns + item];
let remainder = raw - fitted;
let explained = if raw.abs() > SINGULAR_FLOOR {
Some(fitted * fitted / (raw * raw))
} else if fitted.abs() <= SINGULAR_FLOOR {
Some(0.0)
} else {
None
};
Comment thread
seonghobae marked this conversation as resolved.
let share = if raw.abs() > SINGULAR_FLOOR {
Some(2.0 * fitted * remainder / (raw * raw))
} else if fitted.abs() <= SINGULAR_FLOOR && remainder.abs() <= SINGULAR_FLOOR {
Expand All @@ -231,6 +241,7 @@ pub fn residual_interaction_map(
None
};
reconstruction.push(fitted);
explained_share.push(explained.filter(|value| value.is_finite()));
unexplained.push(remainder);
cross_share.push(share.filter(|value| value.is_finite()));
}
Expand All @@ -248,6 +259,7 @@ pub fn residual_interaction_map(
residual,
distance,
reconstruction,
explained_share,
unexplained,
cross_share,
axis_count,
Expand Down Expand Up @@ -308,11 +320,19 @@ mod tests {
assert!(map.person_coordinates.is_empty());
assert!(map.item_coordinates.is_empty());
assert!(map.reconstruction.is_empty());
assert!(map.explained_share.is_empty());
assert!(map.unexplained.is_empty());
assert!(map.cross_share.is_empty());
assert_eq!(map.axis_shares, vec![0.0, 0.0]);
}

#[test]
fn reports_cellwise_squared_reconstruction_share() {
let map = residual_interaction_map(&[2.0, 0.0, 0.0, 2.0], &[1.0, 1.0, 1.0, 1.0], 2, 2, 2)
.unwrap();
assert_eq!(map.explained_share, vec![Some(1.0); 4]);
}

#[test]
fn rejects_nonfinite_expected_before_complete_case_filtering() {
let error = residual_interaction_map(&[1.0], &[f64::NAN], 1, 1, 1).unwrap_err();
Expand Down
14 changes: 10 additions & 4 deletions docs/adr/0021-residual-interaction-map-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,27 @@ fast-mlsirm owns `residual_interaction_map(observed, expected, axis_count)`.
The Rust core computes `R = Y - E`, excludes incomplete rows and columns
without zero filling, centers the admitted residual rectangle, performs the
Gabriel symmetric factorization, and returns coordinates, singular values,
axis inertia, truncated reconstruction `Rhat`, `U = R - Rhat`, and the exact
algebraic cross term `2 Rhat U / R^2`. The number of retained axes is required
axis inertia, truncated reconstruction `Rhat`, `U = R - Rhat`, the cellwise
squared reconstruction share `Rhat^2 / R^2`, and the exact algebraic cross term
`2 Rhat U / R^2`. A zero residual reconstructed as zero has explained share
zero; any other zero-residual case is unavailable. The share remains unclamped
because an overshooting reconstruction is evidence, not a probability. The
number of retained axes is required
from the caller; the library does not invent a display dimension.

The contract contains array indices only. Products retain responsibility for
domain identifiers, authorization, persistence, closest/farthest selection,
and UI wording. A consumer must not reproduce the factorization or cross-term
arithmetic locally.
and UI wording. A consumer must not reproduce the factorization,
reconstruction-share, or cross-term arithmetic locally.

## Consequences

- Numerical behavior is reusable, Rust-owned, and independently testable.
- Missing cells remain absent rather than becoming evidence-valued zeros.
- The cross term is an auditable algebraic identity, not a fitted weight,
threshold, quality score, or heuristic.
- The squared reconstruction share is cell-local diagnostic evidence and may
exceed one; products must not present it as a probability or clamp it.
- This API does not fit an LSIRM or replace the caller's GRM/GPCM fit.

## References
Expand Down
8 changes: 8 additions & 0 deletions docs/changelog.d/residual-map-explained-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Residual-map explained share

## Added

- The Rust-owned residual interaction-map result now returns the cellwise
squared reconstruction share alongside reconstruction, unexplained residual,
and cross-share evidence. Python consumers receive the value as a projection
of the native result and do not recalculate the psychometric arithmetic.
5 changes: 5 additions & 0 deletions python/fast_mlsirm/interaction_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class ResidualInteractionMap:
residual: np.ndarray
distance: np.ndarray
reconstruction: np.ndarray
explained_share: np.ndarray
unexplained: np.ndarray
cross_share: np.ndarray

Expand Down Expand Up @@ -270,6 +271,10 @@ def residual_interaction_map(
reconstruction=np.asarray(raw["reconstruction"], dtype=np.float64).reshape(
rows, columns
),
explained_share=np.asarray(
[np.nan if value is None else value for value in raw["explained_share"]],
dtype=np.float64,
).reshape(rows, columns),
unexplained=np.asarray(raw["unexplained"], dtype=np.float64).reshape(
rows, columns
),
Expand Down
3 changes: 3 additions & 0 deletions tests/test_interaction_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def _fake_map_payload(axis_count: int) -> dict[str, object]:
"residual": [1.0],
"distance": [0.0],
"reconstruction": [1.0],
"explained_share": [1.0],
"unexplained": [0.0],
"cross_share": [0.0],
"axis_count": axis_count,
Expand All @@ -57,6 +58,7 @@ def test_residual_interaction_map_preserves_rank_one_reconstruction() -> None:
assert np.all(result.distance >= 0.0)
np.testing.assert_allclose(result.axis_shares, [1.0, 0.0], atol=1e-12)
np.testing.assert_allclose(result.unexplained, 0.0, atol=1e-12)
np.testing.assert_allclose(result.explained_share, 1.0, atol=1e-12)
np.testing.assert_allclose(result.cross_share, 0.0, atol=1e-12)


Expand Down Expand Up @@ -85,6 +87,7 @@ def test_no_complete_respondent_returns_shape_consistent_empty_map() -> None:
assert result.residual.shape == (0, 0)
assert result.distance.shape == (0, 0)
assert result.reconstruction.shape == (0, 0)
assert result.explained_share.shape == (0, 0)
assert result.unexplained.shape == (0, 0)
assert result.cross_share.shape == (0, 0)
np.testing.assert_array_equal(result.axis_shares, [0.0, 0.0])
Expand Down
Loading