Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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.

## 2025-05-19 - Vectorized Newton-Raphson Updates
**Learning:** In optimization procedures (like the M-step in EM algorithms), executing independent Newton-Raphson steps via a Python `for` loop over individual parameters (e.g., items) adds substantial loop overhead. This slows down processing significantly for models with many items.
**Action:** Vectorize iterative parameter updates using NumPy operations across all parameters simultaneously. Use an active boolean mask (`active = np.ones(n_items, dtype=bool)`) to filter out parameters that have converged and only calculate updates for the still-active subset to maximize computational efficiency.
68 changes: 44 additions & 24 deletions python/fast_mlsirm/estimators/mmle.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,30 +121,50 @@ 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
active = np.ones(n_items, dtype=bool)
nodes_sq = nodes * nodes

# Vectorized Newton steps on the expected log-likelihood over nodes.
for _ in range(25):
if not active.any():
break

ai = a_new[active]
bi = b_new[active]

eta = ai[:, None] * nodes[None, :] + bi[:, None]
p = _sigmoid(eta)

n_iq_active = n_iq[active]
r_iq_active = r_iq[active]

w = n_iq_active * p * (1.0 - p)
resid = r_iq_active - n_iq_active * p

g_a = resid @ nodes - ridge_a * ai
g_b = resid.sum(axis=1) - ridge_b * bi

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
valid_det = np.abs(det) >= 1e-12

da = np.zeros_like(ai)
db = np.zeros_like(bi)

if valid_det.any():
da[valid_det] = (h_bb[valid_det] * g_a[valid_det] - h_ab[valid_det] * g_b[valid_det]) / det[valid_det]
db[valid_det] = (h_aa[valid_det] * g_b[valid_det] - h_ab[valid_det] * g_a[valid_det]) / det[valid_det]

a_new[active] -= da
b_new[active] -= db
a_new[active] = np.clip(a_new[active], 1e-3, 10.0)

converged = (np.abs(da) + np.abs(db)) < 1e-8
still_active = ~converged & valid_det
active[active] = still_active

a, b = a_new, b_new

Expand Down
Loading