Five users have already rated some films. You are User 6. Click the stars to rate the films you know — then press Predict to see the algorithm fill in what it thinks you'd give the ones you haven't rated yet.
<p class="hint">Five users have rated some films (★ = 1–5). <b>You are User 6.</b>
Click the stars on the bottom row to rate films you know, then press <b>Predict</b>
to fill in what the algorithm thinks you'd give the rest.</p>
<div id="app">
<div id="table-wrap"></div>
<div class="status" id="status">Rate at least one film, then click Predict.</div>
<div class="btns">
<button id="btn-predict" type="button">Predict</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .8rem; line-height: 1.5; }
#table-wrap { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; min-width: 460px; }
th, td { padding: 5px 7px; text-align: center; border: 1px solid #dde3ea; }
th { background: #e8eef3; color: #1d3557; font-weight: 700; font-size: .82rem; }
td.user-label { text-align: left; font-weight: 600; color: #1d3557; white-space: nowrap; }
td.you { background: #f0f7ff; }
td.you.predicted { background: #e0f0e8; }
.stars { display: inline-flex; gap: 1px; cursor: pointer; }
.star { font-size: 17px; color: #ccc; line-height: 1; transition: color .1s; }
.star.on { color: #f4a100; }
.star.predicted { color: #3bb065; cursor: default; font-size: 15px; }
.empty { color: #bbb; font-size: .8rem; }
.status { font-size: .95rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .4rem .85rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Collaborative Filtering via Alternating Least Squares (ALS) matrix factorization.
// R[u][i] = known rating (1-5) or 0 (unknown). We factor R ≈ U * V^T where
// U is (nUsers x K) user-factor matrix, V is (nItems x K) item-factor matrix.
// ALS alternates fixing V to solve for U (closed-form least squares) and vice-versa.
// Predictions for unknown entries = row of U dotted with column of V.
const FILMS = ['Inception','The Matrix','Interstellar','Parasite','Spirited Away','Dune'];
const USER_LABELS = ['Alice','Bob','Carol','Dan','Eve','You'];
const K = 2; // latent dimensions
const LAMBDA = 0.1; // regularisation
const ITERS = 40;
// Known ratings (users 0-4, films 0-5); 0 = unrated.
const KNOWN = [
[5, 4, 5, 0, 0, 4],
[4, 5, 4, 0, 0, 5],
[0, 0, 0, 5, 4, 0],
[0, 0, 0, 4, 5, 0],
[5, 0, 4, 3, 0, 3],
];
const nUsers = USER_LABELS.length;
const nItems = FILMS.length;
// You = row 5 (index 5), initially all 0.
let R = KNOWN.map(r => [...r]);
R.push(new Array(nItems).fill(0));
let userRatings = new Array(nItems).fill(0); // your ratings
function rand() { return (Math.random() - 0.5) * 0.5; }
function matMul(A, B) {
// A: m x p, B: p x n -> m x n
const m = A.length, p = B.length, n = B[0].length;
const C = Array.from({length: m}, () => new Array(n).fill(0));
for (let i = 0; i < m; i++)
for (let k = 0; k < p; k++)
for (let j = 0; j < n; j++)
C[i][j] += A[i][k] * B[k][j];
return C;
}
function dot(a, b) { return a.reduce((s, v, i) => s + v * b[i], 0); }
// Solve (V^T * V + lambda*I) * u = V^T * r_known for user u's factor vector.
function solveUser(V, ratings) {
const observed = ratings.map((r, i) => r > 0 ? i : -1).filter(i => i >= 0);
if (observed.length === 0) return new Array(K).fill(0);
// Build V_obs (observed x K) and r_obs vector.
const Vo = observed.map(i => V[i]);
const ro = observed.map(i => ratings[i]);
// A = Vo^T * Vo + lambda * I
const A = Array.from({length: K}, (_, a) =>
Array.from({length: K}, (_, b) =>
Vo.reduce((s, row) => s + row[a] * row[b], 0) + (a === b ? LAMBDA : 0)
)
);
// b = Vo^T * ro
const bv = Array.from({length: K}, (_, a) =>
Vo.reduce((s, row, j) => s + row[a] * ro[j], 0)
);
// Solve 2x2 system A*x = b exactly.
if (K === 2) {
const det = A[0][0]*A[1][1] - A[0][1]*A[1][0];
if (Math.abs(det) < 1e-9) return new Array(K).fill(0);
return [
(A[1][1]*bv[0] - A[0][1]*bv[1]) / det,
(A[0][0]*bv[1] - A[1][0]*bv[0]) / det
];
}
return bv; // fallback K=1
}
function solveItem(U, ratings) {
// ratings[u] = rating by user u for this item (0 = unrated).
return solveUser(U, ratings); // symmetric role
}
function runALS(R_full) {
// Initialise U, V randomly.
let U = Array.from({length: nUsers}, () => Array.from({length: K}, rand));
let V = Array.from({length: nItems}, () => Array.from({length: K}, rand));
for (let iter = 0; iter < ITERS; iter++) {
// Fix V, solve for each user row.
for (let u = 0; u < nUsers; u++)
U[u] = solveUser(V, R_full[u]);
// Fix U, solve for each item row.
for (let i = 0; i < nItems; i++) {
const col = R_full.map(row => row[i]);
V[i] = solveItem(U, col);
}
}
return { U, V };
}
function predict(U, V, u, i) {
return Math.min(5, Math.max(1, dot(U[u], V[i])));
}
// ── DOM ──────────────────────────────────────────────────────────────────────
let predictions = null;
function starHtml(rating, userId, filmIdx, interactive) {
if (!interactive) {
// Static display for known users.
if (rating === 0) return '<span class="empty">·</span>';
return '<span class="stars">' +
[1,2,3,4,5].map(s =>
`<span class="star ${s <= rating ? 'on' : ''}">★</span>`
).join('') + '</span>';
}
// Interactive stars for "you" row.
const isPredicted = (rating === 0 && predictions !== null && predictions[filmIdx] > 0);
const displayRating = isPredicted
? Math.round(predictions[filmIdx])
: rating;
const starClass = isPredicted ? 'predicted' : (rating > 0 ? 'on' : '');
return '<span class="stars" data-film="' + filmIdx + '">' +
[1,2,3,4,5].map(s =>
`<span class="star ${s <= displayRating ? starClass : ''}">★</span>`
).join('') + '</span>';
}
function render() {
const wrap = document.getElementById('table-wrap');
let html = '<table><thead><tr><th>User</th>';
FILMS.forEach(f => { html += '<th>' + f + '</th>'; });
html += '</tr></thead><tbody>';
USER_LABELS.forEach((label, u) => {
const isYou = (u === nUsers - 1);
html += '<tr>';
html += '<td class="user-label' + (isYou ? ' you' : '') + '">' + label + '</td>';
FILMS.forEach((_, i) => {
const rating = isYou ? userRatings[i] : KNOWN[u][i];
const isPred = isYou && userRatings[i] === 0 && predictions !== null;
html += '<td class="' + (isYou ? 'you' : '') + (isPred ? ' predicted' : '') + '">';
html += starHtml(rating, u, i, isYou);
html += '</td>';
});
html += '</tr>';
});
html += '</tbody></table>';
wrap.innerHTML = html;
// Attach click listeners to your star rows.
wrap.querySelectorAll('.stars[data-film]').forEach(starsEl => {
const filmIdx = parseInt(starsEl.getAttribute('data-film'));
starsEl.querySelectorAll('.star').forEach((star, s) => {
star.addEventListener('click', () => {
const newRating = (userRatings[filmIdx] === s + 1) ? 0 : s + 1;
userRatings[filmIdx] = newRating;
predictions = null; // clear predictions when user changes ratings
setStatus('Rate at least one film, then click Predict.', '');
render();
});
});
});
}
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t;
s.className = 'status ' + cls;
}
document.getElementById('btn-predict').addEventListener('click', () => {
const rated = userRatings.filter(r => r > 0).length;
if (rated === 0) {
setStatus('Please rate at least one film first.', 'bad');
return;
}
// Build full matrix with your ratings in row nUsers-1.
R[nUsers - 1] = [...userRatings];
const { U, V } = runALS(R);
predictions = FILMS.map((_, i) => userRatings[i] > 0 ? 0 : predict(U, V, nUsers - 1, i));
const numPred = predictions.filter(p => p > 0).length;
if (numPred === 0) {
setStatus('You rated all films — nothing left to predict!', 'ok');
} else {
setStatus(
fmt('Predicted %numPred% %v% (green stars) from %rated% %v2% you gave.', { numPred: numPred, v: numPred > 1 ? 'films' : 'film', rated: rated, v2: rated > 1 ? 'ratings' : 'rating' }),
'ok'
);
console.log('ALS predictions for you:', FILMS.map((f, i) => f + ':' + (userRatings[i] > 0 ? fmt('%v%(known)', { v: userRatings[i] }) : predictions[i].toFixed(2))).join(', '));
}
render();
});
document.getElementById('btn-reset').addEventListener('click', () => {
userRatings = new Array(nItems).fill(0);
predictions = null;
R[nUsers - 1] = new Array(nItems).fill(0);
setStatus('Rate at least one film, then click Predict.', '');
render();
});
render();
<p class="hint">Five users have rated some films (★ = 1–5). <b>You are User 6.</b>
Click the stars on the bottom row to rate films you know, then press <b>Predict</b>
to fill in what the algorithm thinks you'd give the rest.</p>
<div id="app">
<div id="table-wrap"></div>
<div class="status" id="status">Rate at least one film, then click Predict.</div>
<div class="btns">
<button id="btn-predict" type="button">Predict</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .8rem; line-height: 1.5; }
#table-wrap { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; min-width: 460px; }
th, td { padding: 5px 7px; text-align: center; border: 1px solid #dde3ea; }
th { background: #e8eef3; color: #1d3557; font-weight: 700; font-size: .82rem; }
td.user-label { text-align: left; font-weight: 600; color: #1d3557; white-space: nowrap; }
td.you { background: #f0f7ff; }
td.you.predicted { background: #e0f0e8; }
.stars { display: inline-flex; gap: 1px; cursor: pointer; }
.star { font-size: 17px; color: #ccc; line-height: 1; transition: color .1s; }
.star.on { color: #f4a100; }
.star.predicted { color: #3bb065; cursor: default; font-size: 15px; }
.empty { color: #bbb; font-size: .8rem; }
.status { font-size: .95rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .4rem .85rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Collaborative Filtering via Alternating Least Squares (ALS) matrix factorization.
// R[u][i] = known rating (1-5) or 0 (unknown). We factor R ≈ U * V^T where
// U is (nUsers x K) user-factor matrix, V is (nItems x K) item-factor matrix.
// ALS alternates fixing V to solve for U (closed-form least squares) and vice-versa.
// Predictions for unknown entries = row of U dotted with column of V.
const FILMS = ['Inception','The Matrix','Interstellar','Parasite','Spirited Away','Dune'];
const USER_LABELS = ['Alice','Bob','Carol','Dan','Eve','You'];
const K = 2; // latent dimensions
const LAMBDA = 0.1; // regularisation
const ITERS = 40;
// Known ratings (users 0-4, films 0-5); 0 = unrated.
const KNOWN = [
[5, 4, 5, 0, 0, 4],
[4, 5, 4, 0, 0, 5],
[0, 0, 0, 5, 4, 0],
[0, 0, 0, 4, 5, 0],
[5, 0, 4, 3, 0, 3],
];
const nUsers = USER_LABELS.length;
const nItems = FILMS.length;
// You = row 5 (index 5), initially all 0.
let R = KNOWN.map(r => [...r]);
R.push(new Array(nItems).fill(0));
let userRatings = new Array(nItems).fill(0); // your ratings
function rand() { return (Math.random() - 0.5) * 0.5; }
function matMul(A, B) {
// A: m x p, B: p x n -> m x n
const m = A.length, p = B.length, n = B[0].length;
const C = Array.from({length: m}, () => new Array(n).fill(0));
for (let i = 0; i < m; i++)
for (let k = 0; k < p; k++)
for (let j = 0; j < n; j++)
C[i][j] += A[i][k] * B[k][j];
return C;
}
function dot(a, b) { return a.reduce((s, v, i) => s + v * b[i], 0); }
// Solve (V^T * V + lambda*I) * u = V^T * r_known for user u's factor vector.
function solveUser(V, ratings) {
const observed = ratings.map((r, i) => r > 0 ? i : -1).filter(i => i >= 0);
if (observed.length === 0) return new Array(K).fill(0);
// Build V_obs (observed x K) and r_obs vector.
const Vo = observed.map(i => V[i]);
const ro = observed.map(i => ratings[i]);
// A = Vo^T * Vo + lambda * I
const A = Array.from({length: K}, (_, a) =>
Array.from({length: K}, (_, b) =>
Vo.reduce((s, row) => s + row[a] * row[b], 0) + (a === b ? LAMBDA : 0)
)
);
// b = Vo^T * ro
const bv = Array.from({length: K}, (_, a) =>
Vo.reduce((s, row, j) => s + row[a] * ro[j], 0)
);
// Solve 2x2 system A*x = b exactly.
if (K === 2) {
const det = A[0][0]*A[1][1] - A[0][1]*A[1][0];
if (Math.abs(det) < 1e-9) return new Array(K).fill(0);
return [
(A[1][1]*bv[0] - A[0][1]*bv[1]) / det,
(A[0][0]*bv[1] - A[1][0]*bv[0]) / det
];
}
return bv; // fallback K=1
}
function solveItem(U, ratings) {
// ratings[u] = rating by user u for this item (0 = unrated).
return solveUser(U, ratings); // symmetric role
}
function runALS(R_full) {
// Initialise U, V randomly.
let U = Array.from({length: nUsers}, () => Array.from({length: K}, rand));
let V = Array.from({length: nItems}, () => Array.from({length: K}, rand));
for (let iter = 0; iter < ITERS; iter++) {
// Fix V, solve for each user row.
for (let u = 0; u < nUsers; u++)
U[u] = solveUser(V, R_full[u]);
// Fix U, solve for each item row.
for (let i = 0; i < nItems; i++) {
const col = R_full.map(row => row[i]);
V[i] = solveItem(U, col);
}
}
return { U, V };
}
function predict(U, V, u, i) {
return Math.min(5, Math.max(1, dot(U[u], V[i])));
}
// ── DOM ──────────────────────────────────────────────────────────────────────
let predictions = null;
function starHtml(rating, userId, filmIdx, interactive) {
if (!interactive) {
// Static display for known users.
if (rating === 0) return '<span class="empty">·</span>';
return '<span class="stars">' +
[1,2,3,4,5].map(s =>
`<span class="star ${s <= rating ? 'on' : ''}">★</span>`
).join('') + '</span>';
}
// Interactive stars for "you" row.
const isPredicted = (rating === 0 && predictions !== null && predictions[filmIdx] > 0);
const displayRating = isPredicted
? Math.round(predictions[filmIdx])
: rating;
const starClass = isPredicted ? 'predicted' : (rating > 0 ? 'on' : '');
return '<span class="stars" data-film="' + filmIdx + '">' +
[1,2,3,4,5].map(s =>
`<span class="star ${s <= displayRating ? starClass : ''}">★</span>`
).join('') + '</span>';
}
function render() {
const wrap = document.getElementById('table-wrap');
let html = '<table><thead><tr><th>User</th>';
FILMS.forEach(f => { html += '<th>' + f + '</th>'; });
html += '</tr></thead><tbody>';
USER_LABELS.forEach((label, u) => {
const isYou = (u === nUsers - 1);
html += '<tr>';
html += '<td class="user-label' + (isYou ? ' you' : '') + '">' + label + '</td>';
FILMS.forEach((_, i) => {
const rating = isYou ? userRatings[i] : KNOWN[u][i];
const isPred = isYou && userRatings[i] === 0 && predictions !== null;
html += '<td class="' + (isYou ? 'you' : '') + (isPred ? ' predicted' : '') + '">';
html += starHtml(rating, u, i, isYou);
html += '</td>';
});
html += '</tr>';
});
html += '</tbody></table>';
wrap.innerHTML = html;
// Attach click listeners to your star rows.
wrap.querySelectorAll('.stars[data-film]').forEach(starsEl => {
const filmIdx = parseInt(starsEl.getAttribute('data-film'));
starsEl.querySelectorAll('.star').forEach((star, s) => {
star.addEventListener('click', () => {
const newRating = (userRatings[filmIdx] === s + 1) ? 0 : s + 1;
userRatings[filmIdx] = newRating;
predictions = null; // clear predictions when user changes ratings
setStatus('Rate at least one film, then click Predict.', '');
render();
});
});
});
}
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t;
s.className = 'status ' + cls;
}
document.getElementById('btn-predict').addEventListener('click', () => {
const rated = userRatings.filter(r => r > 0).length;
if (rated === 0) {
setStatus('Please rate at least one film first.', 'bad');
return;
}
// Build full matrix with your ratings in row nUsers-1.
R[nUsers - 1] = [...userRatings];
const { U, V } = runALS(R);
predictions = FILMS.map((_, i) => userRatings[i] > 0 ? 0 : predict(U, V, nUsers - 1, i));
const numPred = predictions.filter(p => p > 0).length;
if (numPred === 0) {
setStatus('You rated all films — nothing left to predict!', 'ok');
} else {
setStatus(
fmt('Predicted %numPred% %v% (green stars) from %rated% %v2% you gave.', { numPred: numPred, v: numPred > 1 ? 'films' : 'film', rated: rated, v2: rated > 1 ? 'ratings' : 'rating' }),
'ok'
);
console.log('ALS predictions for you:', FILMS.map((f, i) => f + ':' + (userRatings[i] > 0 ? fmt('%v%(known)', { v: userRatings[i] }) : predictions[i].toFixed(2))).join(', '));
}
render();
});
document.getElementById('btn-reset').addEventListener('click', () => {
userRatings = new Array(nItems).fill(0);
predictions = null;
R[nUsers - 1] = new Array(nItems).fill(0);
setStatus('Rate at least one film, then click Predict.', '');
render();
});
render();
The algorithm factors the ratings matrix into two smaller matrices — one capturing each user's hidden "taste profile", one capturing each film's hidden "genre fingerprint". Their dot product reconstructs missing entries. Notice that films you haven't rated appear with a predicted score: the model infers your taste from users who rated things similarly to you.
Comments
Loading comments...