Сначала зафиксируйте grain данных, допущения, метрику и риск leakage, затем обсуждайте модель, инструмент или инфраструктуру.
Вопросы и ответы
9 подробных ответов
01Реализуй k-means на numpy — без двойных циклов. Как посчитать дистанции векторизованно и что делать с пустым кластером?
middle
Короткий ответ: Цикл из двух шагов: назначить каждую точку ближайшему центроиду и пересчитать центроиды как средние своих кластеров — пока центроиды не перестанут двигаться. Дистанции считаются одним broadcasting-выражением, без циклов по точкам.
Подробно:
- Дистанции одной строкой —
((X[:, None, :] - C[None, :, :])**2).sum(-1)даёт матрицу (n, k). На больших данных — разложение ‖x‖² − 2xᵀc + ‖c‖², чтобы не материализовать тензор (n, k, d). - Назначение и обновление —
argminпо оси кластеров, затем среднее точек каждого кластера. - Пустой кластер — главная ловушка — если к центроиду никого не приписали,
meanдаст NaN; пере-сеиваем его случайной точкой данных. - Останов — по сдвигу центроидов, не только по числу итераций.
import numpy as np
def kmeans(X, k, iters=100, tol=1e-6, seed=0):
rng = np.random.default_rng(seed)
C = X[rng.choice(len(X), k, replace=False)]
for _ in range(iters):
d2 = ((X[:, None, :] - C[None, :, :])**2).sum(-1) # (n, k)
labels = d2.argmin(1)
newC = C.copy()
for j in range(k): # цикл только по k — это ок
pts = X[labels == j]
# пустой кластер -> пере-сеиваем случайной точкой
newC[j] = pts.mean(0) if len(pts) else X[rng.integers(len(X))]
if np.linalg.norm(newC - C) < tol:
break
C = newC
return C, labels
⚠️ Частая ошибка: двойной цикл по точкам и центроидам вместо broadcasting — и молчаливый NaN от пустого кластера: интервьюер почти всегда спрашивает именно про него.
02Напиши логистическую регрессию с батчевым градиентным спуском. Почему градиент — просто Xᵀ(p − y)/n?
middle
Короткий ответ: Модель p = σ(Xw), лосс — бинарная кросс-энтропия, а её градиент схлопывается до Xᵀ(p − y)/n. Остаётся стабильный сигмоид, клиппинг вероятностей в лоссе и цикл спуска; bias — колонка единиц в X.
Подробно:
- Стабильный сигмоид — наивный
1/(1+exp(-z))переполняется при больших |z|; ветвим по знаку z. - Лосс без log(0) — при насыщении сигмоида p упирается в 0 или 1; клиппим
np.clip(p, eps, 1-eps)либо считаем черезlogaddexp. - Почему градиент чистый — цепное правило: dL/dp × dp/dz даёт σ′, которая сокращается со знаменателем производной лога, остаётся (p − y). Отсюда Xᵀ(p − y)/n — любимый follow-up.
import numpy as np
def sigmoid(z):
out = np.empty_like(z) # стабильно для больших |z|
pos = z >= 0
out[pos] = 1 / (1 + np.exp(-z[pos]))
e = np.exp(z[~pos])
out[~pos] = e / (1 + e)
return out
def fit(X, y, lr=0.1, epochs=1000, eps=1e-12):
Xb = np.hstack([X, np.ones((len(X), 1))]) # bias — колонка единиц
w = np.zeros(Xb.shape[1])
for _ in range(epochs):
p = sigmoid(Xb @ w)
pc = np.clip(p, eps, 1 - eps) # log(0) не случится
loss = -np.mean(y*np.log(pc) + (1-y)*np.log(1-pc))
w -= lr * Xb.T @ (p - y) / len(y)
return w
⚠️ Частая ошибка: log(p) без клиппинга — насыщенный сигмоид даёт log(0) = −inf и NaN; и забытый bias — граница решения обязана проходить через ноль.
03Реализуй scaled dot-product self-attention на numpy с каузальной маской. Где именно ставится маска и зачем делить на √d_k?
senior
Короткий ответ: scores = QKᵀ/√d_k; верхний треугольник (будущее) заливаем −inf ДО softmax; softmax — по последней оси и со сдвигом на максимум строки; выход — weights @ V.
Подробно:
- Зачем √d_k — дисперсия скалярного произведения растёт с размерностью; без нормировки softmax насыщается и градиенты исчезают. Стандартный follow-up: «а зачем корень?»
- Маска строго до softmax — −inf после экспоненты превращается в ровно 0 веса, а строка по-прежнему суммируется в 1. Занулять веса после softmax — сломать нормировку.
- Стабильный softmax — вычесть максимум строки перед exp.
- Ось — softmax по ключам (последняя ось scores), не по запросам.
import numpy as np
def softmax(x, axis=-1):
x = x - x.max(axis=axis, keepdims=True) # стабильность
e = np.exp(x)
return e / e.sum(axis=axis, keepdims=True)
def attention(Q, K, V, causal=True):
d_k = Q.shape[-1]
scores = Q @ K.swapaxes(-1, -2) / np.sqrt(d_k)
if causal:
T = scores.shape[-1]
mask = np.triu(np.ones((T, T), dtype=bool), k=1)
scores = np.where(mask, -np.inf, scores) # ДО softmax!
w = softmax(scores, axis=-1) # по оси ключей
return w @ V
⚠️ Частая ошибка: softmax не по той оси — и маскирование после softmax: веса перестают суммироваться в единицу, а модель тихо подглядывает в будущее.
04Реализуй forward-проход BatchNorm для train- и eval-режимов. Чем они отличаются и почему это критично?
senior
Короткий ответ: В train нормируем по статистикам текущего батча и обновляем бегущие mean/var с momentum; в eval используем ТОЛЬКО бегущие статистики. Затем scale-shift: y = γ·x̂ + β.
Подробно:
- Train — μ, σ² по батчу, x̂ = (x − μ)/√(σ² + ε), running-статистики обновляются экспоненциальным средним.
- Eval — batch-статистики брать нельзя: предсказание начинает зависеть от состава батча, а при batch=1 дисперсия равна нулю.
- γ и β — обучаемые; дают слою право при необходимости отменить нормализацию.
- Почему LayerNorm иначе — она нормирует по признакам одного примера, не по батчу, поэтому не имеет режимов и живёт в трансформерах и RNN.
import numpy as np
class BatchNorm:
def __init__(self, d, momentum=0.1, eps=1e-5):
self.gamma, self.beta = np.ones(d), np.zeros(d)
self.run_mean, self.run_var = np.zeros(d), np.ones(d)
self.momentum, self.eps = momentum, eps
def __call__(self, x, training):
if training:
mu, var = x.mean(0), x.var(0)
m = self.momentum
self.run_mean = (1 - m) * self.run_mean + m * mu
self.run_var = (1 - m) * self.run_var + m * var
else:
mu, var = self.run_mean, self.run_var # НЕ batch-статистики!
x_hat = (x - mu) / np.sqrt(var + self.eps)
return self.gamma * x_hat + self.beta
⚠️ Частая ошибка: один режим на все случаи — batch-статистики на инференсе. Метрики скачут от размера и состава батча, при batch=1 всё ломается. Именно это переключает model.eval() в PyTorch.
05Напиши численно стабильные softmax и cross-entropy. Почему нельзя считать softmax, а потом log?
junior
Короткий ответ: Перед exp вычитаем максимум строки — уже exp(89) переполняет float32. Для лосса softmax и log не считаем по отдельности, а объединяем в log-sum-exp: CE = logsumexp(z) − z[y].
Подробно:
- Сдвиг на максимум — softmax инвариантен к сдвигу: exp(z − m)/Σexp(z − m) — та же величина, но без overflow.
- log-softmax одним куском — log(softmax(z)) = z − m − log Σ exp(z − m); нигде не берём log от почти-нуля.
- CE как индексирование — минус log-softmax в позиции правильного класса, среднее по батчу.
import numpy as np
def log_softmax(z):
z = z - z.max(axis=1, keepdims=True) # exp(89) переполняет fp32
return z - np.log(np.exp(z).sum(axis=1, keepdims=True))
def cross_entropy(z, y):
# CE = logsumexp(z) - z[y]; никакого softmax -> log по отдельности
n = len(y)
return -log_softmax(z)[np.arange(n), y].mean()
⚠️ Частая ошибка: посчитать softmax, потом взять log — при насыщении вероятность округляется до 0 и log даёт −inf. Именно поэтому F.cross_entropy в PyTorch принимает логиты, а не вероятности.
06Напиши по памяти полный цикл обучения и валидации на PyTorch. Какие три вещи чаще всего забывают?
middle
Короткий ответ: Train: model.train(), затем цикл zero_grad → forward → loss → backward → step. Eval: model.eval() плюс torch.no_grad(), метрики копим взвешенно по размеру батча.
Подробно:
- zero_grad обязателен — PyTorch суммирует градиенты в
.grad; забудешь — получишь градиенты сразу за несколько батчей. - eval() и no_grad() — разные вещи — первый переключает dropout и BatchNorm на инференс-режим, второй отключает построение графа. Нужны оба.
- Взвешивание метрик — последний батч обычно короче; среднее по батчам ≠ среднее по датасету, умножаем на len(x).
.item()— иначе тензор тянет за собой граф вычислений, и память течёт.
def train_epoch(model, loader, opt, crit, device):
model.train()
for x, y in loader:
x, y = x.to(device), y.to(device)
opt.zero_grad() # иначе градиенты копятся
loss = crit(model(x), y)
loss.backward()
opt.step()
@torch.no_grad()
def evaluate(model, loader, crit, device):
model.eval() # dropout выкл, BN -> running stats
total, n = 0.0, 0
for x, y in loader:
x, y = x.to(device), y.to(device)
loss = crit(model(x), y)
total += loss.item() * len(x) # последний батч короче
n += len(x)
return total / n
⚠️ Частая ошибка: забытый zero_grad — градиенты аккумулируются и лосс «странно скачет»; валидация без model.eval() — dropout продолжает выключать нейроны; лосс без .item() — граф копится в памяти.
07Реализуй top-k и top-p (nucleus) сэмплирование с температурой. В каком порядке применять температуру и отсечение?
middle
Короткий ответ: Сначала делим логиты на температуру, потом отсекаем: top-k оставляет k наибольших логитов, top-p — минимальный префикс отсортированных вероятностей с суммой ≥ p (минимум один токен). После отсечения — перенормировка и сэмплирование.
Подробно:
- Порядок операций — температура меняет форму всего распределения, поэтому идёт до отсечения; T после top-k перевзвешивает уже усечённый набор.
- top-k — порог по k-му логиту (
np.sort(z)[-k]илиargpartition), остальное в −inf. - top-p — сортировка по убыванию, cumsum, срез там, где сумма достигла p;
searchsorted+ 1 включает пересекающий токен. - Перенормировка — после зануления хвоста делим вероятности на новую сумму.
import numpy as np
def sample(logits, T=1.0, k=None, p=None, rng=np.random.default_rng()):
z = logits / T # температура ДО отсечения
if k is not None:
kth = np.sort(z)[-k] # k-й по величине логит
z = np.where(z < kth, -np.inf, z)
probs = np.exp(z - z.max())
probs /= probs.sum()
if p is not None:
order = np.argsort(-probs)
cum = np.cumsum(probs[order])
cut = np.searchsorted(cum, p) + 1 # хотя бы один токен
probs[order[cut:]] = 0.0 # хвост в ноль
probs /= probs.sum() # перенормировка
return rng.choice(len(probs), p=probs)
⚠️ Частая ошибка: температура после отсечения — и срез top-p строго до p без пересекающего токена: на пиковом распределении так можно остаться вовсе без кандидатов.
08Реализуй косинусную близость и brute-force поиск top-k соседей по 1M векторов — быстро. Что векторизуем и где предел брутфорса?
junior
Короткий ответ: Нормируем строки базы один раз — косинус превращается в matmul единичных векторов; top-k берём через np.argpartition за O(n) вместо полной сортировки. Для 1M×d это одна BLAS-операция.
Подробно:
- Нормировка заранее — cos(a, b) = â·b̂; базу нормируем один раз офлайн, на каждый запрос остаётся только матричное умножение.
- argpartition — выбор k наибольших за O(n) без сортировки всего массива; досортировываем только k элементов.
- Нулевые векторы — деление на нулевую норму даёт NaN; добавляем ε к норме.
- Мост к продовому ответу — брутфорс на 1M векторов ещё жив (миллисекунды на BLAS), дальше — ANN: HNSW или IVF (faiss), размен точности на скорость.
import numpy as np
def build_index(X, eps=1e-12):
# один раз офлайн: нормируем строки
return X / (np.linalg.norm(X, axis=1, keepdims=True) + eps)
def top_k(Xn, q, k=10, eps=1e-12):
qn = q / (np.linalg.norm(q) + eps)
sims = Xn @ qn # косинус = matmul единичных
idx = np.argpartition(-sims, k)[:k] # O(n), не полная сортировка
return idx[np.argsort(-sims[idx])] # сортируем только k штук
⚠️ Частая ошибка: np.sort всего массива сходств ради top-10 — O(n log n) вместо O(n); и косинус «в лоб» с перенормировкой всей базы на каждый запрос.
09Встрой в цикл обучения градиентную аккумуляцию — корректно. Что происходит с эффективным learning rate, если забыть поделить лосс?
middle
Короткий ответ: Делим лосс на число шагов аккумуляции, backward — на каждом микробатче (градиенты суммируются в .grad), а optimizer.step() + zero_grad() — раз в accum_steps. Это эмулирует батч в K раз больше без роста памяти.
Подробно:
- Деление лосса обязательно — backward суммирует градиенты; без деления градиент выходит в K раз больше, то есть эффективный learning rate молча умножается на K.
- step/zero_grad по расписанию — только каждые accum_steps микробатчей; zero_grad сразу после step.
- Хвост эпохи — если число батчей не кратно accum_steps, последние накопленные градиенты нужно дошагнуть, иначе они пропадут или утекут в следующую эпоху.
- BatchNorm-оговорка — статистики BN всё равно считаются по физическому микробатчу; аккумуляция их не «склеивает». Для мелких батчей — GroupNorm/LayerNorm.
accum = 4
opt.zero_grad()
for i, (x, y) in enumerate(loader):
loss = crit(model(x), y) / accum # иначе градиенты x K -> LR x K
loss.backward() # градиенты копятся в .grad
if (i + 1) % accum == 0:
opt.step()
opt.zero_grad()
# хвост: батчей не кратно accum — дошагиваем остаток
if (i + 1) % accum != 0:
opt.step()
opt.zero_grad()
⚠️ Частая ошибка: не поделить лосс на accum_steps — обучение «внезапно» расходится, потому что эффективный LR вырос в K раз. И не менять LR «на всякий случай»: при правильном делении он не требует масштабирования.
Источники
Источники и редакционная политика
Материалы RecallDeck сопоставлены с официальной документацией и открытыми публикациями компаний, когда первичный источник доступен. Мы не связаны с упомянутыми работодателями, не публикуем конфиденциальные задания и не продаём места в подборках. Формат найма может меняться — уточняйте его у рекрутера.
От чтения к воспроизведению
Отрепетируйте полный цикл интервью.
RecallDeck возвращает сложные темы по расписанию и помогает удерживать в памяти язык, SQL, архитектуру и поведенческие истории.