Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
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
4 changes: 4 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@
## 2025-05-19 - Dot product scalar gradients allocation
**Learning:** During gradient calculation, `float((e * (-gamma * distance)).sum())` creates two full-size `(N, J)` arrays: one for the scaled distance and one for the element-wise multiplication before reduction.
**Action:** Replace `(A * B).sum()` with `np.vdot(A, B)` when scalar reduction is needed over matrix multiplication (where `B` can incorporate scalars naturally like `-gamma * np.vdot(A, B)`). This entirely avoids the 2D array allocation overhead and yields order-of-magnitude improvements in scalar gradient components.

## 2026-07-14 - Vectorizing outer Python loops in EM optimization
**Learning:** During iterative EM/MMLE steps, doing updates via Python outer `for` loops (e.g. iterating over all items to compute Newton-Raphson updates using `.sum()`) incurs massive Python interpretation and scalar computation overhead, becoming a major performance bottleneck for large item counts.
**Action:** Replace the Python loop over dimensions with fully vectorized batch operations using boolean state masks (`active_mask`) and 2D matrix multiplications (`@` or `np.dot`). This utilizes highly optimized underlying BLAS implementations and can yield 50%+ reduction in computation time for the iteration block.
Comment thread
github-actions[bot] marked this conversation as resolved.
Outdated
10 changes: 7 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@

### Changed

- Vectorized the MMLE-EM M-step Newton-Raphson updates across items with an
active-convergence mask in `python/fast_mlsirm/estimators/mmle.py`, replacing
the per-item Python loop with batched BLAS-backed matrix operations while
preserving the per-item convergence and singular-Hessian break semantics.
- Rust EAP scoring now defaults to GPU-preferred `auto` execution in the core,
PyO3 binding, and serving API. The f64 CPU reduction remains available via
`device="cpu"`; an explicit unavailable `device="gpu"` request now warns
Expand Down Expand Up @@ -2062,9 +2066,9 @@
### Changed

- `estimator="mmle"` with a spatial/multidimensional model now fits (routed to
the marginal estimator) instead of raising `NotImplementedError`; plain
`ULS2PLM`/`ULSRM` without a population structure keep the legacy
unidimensional fast path and its exact previous behavior.
the marginal estimator) instead of raising `NotImplementedError`; plain
`ULS2PLM`/`ULSRM` without a population structure keep the legacy
unidimensional fast path and its exact previous behavior.
Comment thread
seonghobae marked this conversation as resolved.

- Exposed the Rust MMLE-EM estimator (`mlsirm_core::mmle::fit_mmle_2pl`) through
the PyO3 binding as `fast_mlsirm._core.fit_mmle_2pl`, so
Expand Down
76 changes: 52 additions & 24 deletions python/fast_mlsirm/estimators/mmle.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,30 +121,58 @@ def fit_mmle_2pl(

a_new = a.copy()
b_new = b.copy()
for i in range(n_items):
ai, bi = a[i], b[i]
# Newton steps on the item's expected log-likelihood over nodes.
for _ in range(25):
eta = ai * nodes + bi
p = _sigmoid(eta)
w = n_iq[i] * p * (1.0 - p)
resid = r_iq[i] - n_iq[i] * p
g_a = float((resid * nodes).sum()) - ridge_a * ai
g_b = float(resid.sum()) - ridge_b * bi
h_aa = -float((w * nodes * nodes).sum()) - ridge_a
h_bb = -float(w.sum()) - ridge_b
h_ab = -float((w * nodes).sum())
det = h_aa * h_bb - h_ab * h_ab
if abs(det) < 1e-12:
break
da = (h_bb * g_a - h_ab * g_b) / det
db = (h_aa * g_b - h_ab * g_a) / det
ai -= da
bi -= db
ai = float(np.clip(ai, 1e-3, 10.0))
if abs(da) + abs(db) < 1e-8:
break
a_new[i], b_new[i] = ai, bi

# Optimize MMLE M-step by vectorizing Newton-Raphson across active items
nodes_sq = nodes * nodes
active_mask = np.ones(n_items, dtype=bool)

for _ in range(25):
Comment thread
seonghobae marked this conversation as resolved.
if not np.any(active_mask):
break

a_act = a_new[active_mask]
b_act = b_new[active_mask]
n_iq_act = n_iq[active_mask]
r_iq_act = r_iq[active_mask]

eta = a_act[:, None] * nodes[None, :] + b_act[:, None]
p = _sigmoid(eta)
w = n_iq_act * p * (1.0 - p)
resid = r_iq_act - n_iq_act * p
Comment thread
seonghobae marked this conversation as resolved.

# Optimized vector dot products instead of array broadcasting and .sum()
g_a = (resid @ nodes) - ridge_a * a_act
g_b = resid.sum(axis=1) - ridge_b * b_act

h_aa = -(w @ nodes_sq) - ridge_a
h_bb = -w.sum(axis=1) - ridge_b
h_ab = -(w @ nodes)

det = h_aa * h_bb - h_ab * h_ab

# Avoid division by zero, set invalid determinants to 1.0 (da, db will be 0)
valid = np.abs(det) >= 1e-12
Comment thread
seonghobae marked this conversation as resolved.
if not np.all(valid):
det = np.where(valid, det, 1.0)
g_a = np.where(valid, g_a, 0.0)
g_b = np.where(valid, g_b, 0.0)

da = (h_bb * g_a - h_ab * g_b) / det
db = (h_aa * g_b - h_ab * g_a) / det

a_act -= da
b_act -= db
a_act = np.clip(a_act, 1e-3, 10.0)
Comment thread
seonghobae marked this conversation as resolved.

a_new[active_mask] = a_act
b_new[active_mask] = b_act

converged = (np.abs(da) + np.abs(db)) < 1e-8
Comment thread
seonghobae marked this conversation as resolved.

new_converged = converged | (~valid)
if np.any(new_converged):
idx = np.nonzero(active_mask)[0]
active_mask[idx[new_converged]] = False

a, b = a_new, b_new

Expand Down
Loading