Skip to the questions

Data Scientist / ML Engineer · Fresher to 3 years

Machine learning interview questions

The 40 questions that come up most in machine learning, data science and GenAI interviews. Each one has a short answer you can say out loud in about 30 seconds, and the full explanation, with tested Python code, for when you need it.

Read the quick answers

3 Fundamentals · 6 Data prep · 7 Algorithms · 5 Metrics · 3 Training · 5 pandas & NumPy · 4 Deep learning · 2 NLP · 5 GenAIUpdated 27 Oct 2026

When is your interview?

01ML fundamentals

0/3 got it

Q01Not marked yetFresherAsked very oftenConcept

What is the difference between supervised and unsupervised learning? Give one real-world example of each.

Say this first~30 sec to say

Supervised learning trains on labelled examples, inputs paired with the right answer, and learns to predict that answer for new inputs. Predicting a category is classification, like spam or not spam; predicting a number is regression, like a flat's price. Unsupervised learning gets no labels and finds structure by itself: clustering shoppers into segments, or reducing hundreds of features to a few. Spam filtering is supervised; customer segmentation is unsupervised.

Could you say it?
Full answer2 min read
SupervisedUnsupervised
Training dataInputs and the correct outputsInputs only
GoalPredict the label for new dataFind groups, patterns or a simpler representation
Main tasksClassification, regressionClustering, dimensionality reduction, anomaly detection
Example algorithmsLinear and logistic regression, decision trees, random forestsk-means, DBSCAN, PCA
How it's evaluatedAgainst the true labels: accuracy, F1, RMSEHarder: silhouette score, business usefulness
Real exampleWill this loan default? What will this flat sell for?Which customers behave alike? Which transactions look unusual?

The same data, with and without labels

Python
from sklearn.cluster import KMeans
from sklearn.datasets import make_blobs
from sklearn.linear_model import LogisticRegression

X, y = make_blobs(n_samples=300, centers=3, random_state=42)

clf = LogisticRegression().fit(X, y)              # supervised: learns from X and the labels y
print(clf.predict(X[:8]), y[:8])                  # [2 2 0 1 2 1 0 1] [2 2 0 1 2 1 0 1]

km = KMeans(n_clusters=3, n_init=10, random_state=42).fit(X)   # unsupervised: X only
print(km.labels_[:8])                             # [1 1 0 2 1 2 0 2]

k-means found the same three groups, but numbered them differently: without labels, it has no way to know what each group should be called. Naming and interpreting clusters is up to you.

Other kinds you may be asked about

  • Semi-supervised: a few labelled examples plus many unlabelled ones.
  • Self-supervised: the labels come from the data itself. Large language models are pre-trained this way, by predicting the next token of ordinary text.
  • Reinforcement learning: an agent learns from rewards for its actions, as in game-playing or robotics.

Follow-ups

  • Classification or regression: predicting a customer's age? Regression, because age is a number. Predicting an age band such as "18–25" would be classification.
  • Can unsupervised learning help a supervised model? Yes. Cluster labels or PCA components can become features, and anomaly scores can flag rows to check before training.
Q02Not marked yetFresherAsked very oftenConcept

What are overfitting and underfitting, and how does the bias–variance trade-off explain them?

Say this first~30 sec to say

Overfitting is when a model learns the training data too well, noise included: a great training score but a poor one on new data. That's high variance: a slightly different training set would give a very different model. Underfitting is when the model is too simple to capture the pattern, so it does badly on both: high bias. Making a model more complex lowers bias but raises variance, so you look for the complexity where validation error is lowest.

Could you say it?
Full answer2 min read

Seeing it in the scores

Python
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier

X, y = make_classification(n_samples=600, n_features=20, n_informative=5, flip_y=0.1, random_state=0)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=0)

for depth in (1, 4, None):
    tree = DecisionTreeClassifier(max_depth=depth, random_state=0).fit(X_train, y_train)
    print(f"max_depth={depth}: train {tree.score(X_train, y_train):.2f}, test {tree.score(X_test, y_test):.2f}")
max_depth=1: train 0.81, test 0.77
max_depth=4: train 0.87, test 0.79
max_depth=None: train 1.00, test 0.73

A one-split tree is too simple. An unlimited tree memorises the training set, 10% deliberately mislabelled rows included, and scores worst on new data. The depth-4 tree generalises best.

Training scoreValidation scoreDiagnosis
Both lowLowLowUnderfitting: high bias
Big gapHighMuch lowerOverfitting: high variance
Both high and closeHighHighA good fit

How to fix each

OverfittingUnderfitting
More training dataMore informative features
A simpler model: shallower trees, fewer featuresA more flexible model
Regularisation, L1 or L2 (question 23)Less regularisation
Early stopping, dropout for neural networksTrain for longer
Ensembles that average many models, such as random forests (question 13)Boosting, which keeps correcting errors

The trade-off in one line: a model's expected error on new data is bias² + variance + irreducible noise. You can trade bias against variance, but not remove the noise.

Follow-ups

  • How do you detect overfitting in practice? Compare training and validation scores, ideally with cross-validation (question 3), and plot learning curves as the training set grows.
  • Can a model overfit the validation set? Yes, if you tune it against that set many times. That's why a final test set stays untouched until the end.
Q03Not marked yetFresherAsked very oftenConcept

What is k-fold cross-validation, and why is it more reliable than a single train–test split?

Say this first~30 sec to say

Split the training data into k equal folds, often 5 or 10. Train k models, each time holding out a different fold for validation, then average the k scores. Every example is validated exactly once, so the estimate depends far less on one lucky split, and the spread shows how stable it is. Keep a separate test set untouched until the end, and use stratified folds so each keeps the class ratio.

Could you say it?
Full answer2 min read
5-fold cross-validation: each row is one training run
Fold:    1      2      3      4      5
Run 1: [val ] [train][train][train][train]
Run 2: [train][val ] [train][train][train]
Run 3: [train][train][val ] [train][train]
Run 4: [train][train][train][val ] [train]
Run 5: [train][train][train][train][val ]
Python
from sklearn.datasets import load_breast_cancer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score, train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

X, y = load_breast_cancer(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)

model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(model, X_train, y_train, cv=cv)
print(scores.round(3), f"mean {scores.mean():.3f} ± {scores.std():.3f}")
# [0.967 0.989 0.978 0.989 0.967] mean 0.978 ± 0.010

model.fit(X_train, y_train)                         # the final model, on all training data
print(f"test {model.score(X_test, y_test):.3f}")    # test 0.982: the test set, used once

The scaler sits inside the pipeline, so in each fold it's fitted on that fold's training part only. Scaling all the data before splitting would leak information from the validation fold into training (question 8).

Train, validation and test sets

SetUsed forTouched
TrainingFitting the modelConstantly
Validation (or the CV folds)Choosing models and hyperparametersMany times
TestThe final, honest estimateOnce, at the end

When the data is ordered in time, don't shuffle: use TimeSeriesSplit, which always trains on the past and validates on the future, the way the model will actually be used.

Follow-ups

  • How do you choose k? 5 or 10 is the usual balance. Larger k means less bias in the estimate but more training runs; leave-one-out (k = n) is rarely worth the cost.
  • What is nested cross-validation? An outer loop to estimate performance and an inner loop to tune hyperparameters, so tuning can't flatter the reported score.

02Data preparation

0/6 got it

Q04Not marked yetFresherAsked very oftenScenario

A key feature is missing in about 30% of rows. Would you drop the column, drop the rows or fill the gaps, and how would you decide?

Say this first~25 sec to say

First find out why it's missing; missingness itself can be a signal. Dropping 30% of the rows wastes data and can bias the sample, and dropping the column loses a key feature. Usually, impute: the median for skewed numbers, the most frequent value for categories, or a model-based imputer, plus a "was missing" flag so the model can use the pattern. Fit the imputer on training data only.

Could you say it?
Full answer3 min read

Questions to ask before choosing

  • Why is it missing? A sensor that failed at random is harmless to fill. Income that's blank because high earners skipped the question is not random, and the blank itself is informative.
  • How important is the feature? Check how well the model does with and without it.
  • Is the target ever missing? Rows without a target can't be used for training at all; drop those, and never impute the target.
OptionWhen it fitsRisk
Drop the rowsVery few rows affected, missing at randomLosing data, biasing the sample
Drop the columnMostly empty, or unimportantLosing a useful signal
Fill with the median or the most frequent valueThe default for most featuresShrinks the spread; hides the pattern unless you flag it
Model-based imputation (KNN, iterative)The feature can be predicted from othersSlower; more to validate
Leave as missingModels that handle gaps natively, such as HistGradientBoostingClassifier and XGBoostOnly works with those models
Python
import numpy as np
import pandas as pd
from sklearn.impute import SimpleImputer

train = pd.DataFrame({"income": [52_000, np.nan, 61_000, 58_000, np.nan, 250_000]})
test = pd.DataFrame({"income": [np.nan, 49_000]})

imputer = SimpleImputer(strategy="median", add_indicator=True)
imputer.fit(train)                          # learn the median from training data only
print(imputer.statistics_)                  # [59500.]
print(pd.DataFrame(imputer.transform(test), columns=imputer.get_feature_names_out()))
    income  missingindicator_income
0  59500.0                      1.0
1  49000.0                      0.0

Why the median: the mean of this training column is 105,250, dragged up by one ₹2.5 lakh income. The median, 59,500, is typical of the rows.

Follow-ups

  • What are MCAR, MAR and MNAR? Missing completely at random, missing at random (explained by other columns), and missing not at random (explained by the missing value itself). Only the first is safe to drop without bias.
  • Why fit the imputer on training data only? Its median is a statistic of the data. Computing it on everything leaks information about the validation and test rows into training.
Q05Not marked yetFresherAsked very oftenConcept

How do you turn categorical columns into numbers, and when is label encoding a poor choice?

Say this first~25 sec to say

For categories with no order, like city, use one-hot encoding: one 0/1 column each. For ordered ones, like small, medium and large, use ordinal integers. Label-encoding unordered categories (Delhi = 0, Mumbai = 1, Pune = 2) misleads linear and distance-based models with a fake order; trees cope better. For hundreds of categories, group the rare ones or use frequency or target encoding.

Could you say it?
Full answer2 min read
Python
import pandas as pd
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder

df = pd.DataFrame({
    "city": ["Pune", "Delhi", "Pune", "Chennai"],
    "size": ["small", "large", "medium", "small"],
})

onehot = OneHotEncoder(handle_unknown="ignore", sparse_output=False)
print(pd.DataFrame(onehot.fit_transform(df[["city"]]), columns=onehot.get_feature_names_out()))

ordinal = OrdinalEncoder(categories=[["small", "medium", "large"]])   # the order is stated
print(ordinal.fit_transform(df[["size"]]).ravel())                     # [0. 2. 1. 0.]

print(onehot.transform(pd.DataFrame({"city": ["Kochi"]})))             # [[0. 0. 0.]]: unseen city
   city_Chennai  city_Delhi  city_Pune
0           0.0         0.0        1.0
1           0.0         1.0        0.0
2           0.0         0.0        1.0
3           1.0         0.0        0.0

handle_unknown="ignore" matters in production: a city the model never saw becomes all zeros instead of crashing the prediction.

EncodingUse forWatch out for
One-hotUnordered categories with few valuesMany columns for high-cardinality features
OrdinalCategories with a real orderOnly when the order is real
Label (arbitrary integers)Tree models, or the target columnFake order for linear and distance-based models
FrequencyHigh-cardinality columnsDifferent categories with the same count become identical
Target (mean of the target per category)High-cardinality columns with signalLeakage: fit it with cross-fitting, as scikit-learn's TargetEncoder does

Follow-ups

  • What is the dummy variable trap? With all one-hot columns plus an intercept, one column is always predictable from the others, which breaks an unregularised linear regression. Drop one column (drop="first"), or use regularisation.
  • Why not pd.get_dummies? It's fine for exploring, but it doesn't remember the categories it saw, so the test set can end up with different columns. An encoder fitted on training data keeps them consistent.
Q06Not marked yetFresherAsked very oftenConcept

Normalisation vs standardisation: how do they differ, and which models need scaled features?

Say this first~30 sec to say

Normalisation (min-max scaling) squeezes each feature into a fixed range, usually 0 to 1: (x − min) / (max − min). Standardisation rescales to mean 0 and standard deviation 1: (x − mean) / std, with no fixed range, and it's less distorted by a single extreme value. Models that use distances or gradient-based weights need scaled features: KNN, k-means, SVMs, PCA, regularised linear models and neural networks. Tree models don't, since they split one feature at a time.

Could you say it?
Full answer2 min read
Python
from sklearn.datasets import load_wine
from sklearn.model_selection import cross_val_score
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import MinMaxScaler, StandardScaler

X, y = load_wine(return_X_y=True)        # features range from 0.13 to 1,680
knn = KNeighborsClassifier()

print(f"raw:          {cross_val_score(knn, X, y, cv=5).mean():.2f}")                                  # 0.69
print(f"min-max:      {cross_val_score(make_pipeline(MinMaxScaler(), knn), X, y, cv=5).mean():.2f}")   # 0.95
print(f"standardised: {cross_val_score(make_pipeline(StandardScaler(), knn), X, y, cv=5).mean():.2f}") # 0.95

Unscaled, KNN's distances are dominated by the one feature measured in the hundreds, so the other twelve barely count. Scaling puts them on an equal footing.

ModelNeeds scaling?Why
KNN, k-means, SVMYesDistances are dominated by large-valued features
PCAYesIt finds directions of largest variance, which big units inflate
Linear and logistic regression with L1 or L2YesThe penalty treats every weight alike, so units must be comparable
Neural networksYesGradient descent converges faster and more reliably
Decision trees, random forests, gradient boostingNoEach split looks at one feature's order, not its scale

Which to choose: standardisation is the usual default. Min-max suits features with natural bounds, such as pixel values from 0 to 255. With strong outliers, RobustScaler uses the median and the interquartile range instead.

Follow-ups

  • Should you scale the target? Sometimes, for neural network regression; remember to invert the scaling on the predictions.
  • Fit the scaler on what? The training data only, then apply it to validation and test data. A pipeline does this for you.
Q07Not marked yetFresherAsked very oftenConcept

What can you do when one class is far rarer than the others?

Say this first~25 sec to say

Use stratified splits and judge by precision, recall, F1 or PR-AUC, not accuracy. Then: weight the rare class more in the loss (class_weight="balanced"); resample the training data only, oversampling the rare class (or using SMOTE) or undersampling the common one; and move the decision threshold below 0.5 to trade precision for recall. More rare examples beat all of these, when you can get them.

Could you say it?
Full answer2 min read
Python
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_score, recall_score
from sklearn.model_selection import train_test_split

X, y = make_classification(n_samples=5000, weights=[0.97], flip_y=0, random_state=1)
X_train, X_test, y_train, y_test = train_test_split(X, y, stratify=y, test_size=0.3, random_state=1)
print(f"rare class: {y.mean():.1%}")      # rare class: 3.0%

for weights in (None, "balanced"):
    model = LogisticRegression(class_weight=weights, max_iter=1000).fit(X_train, y_train)
    pred = model.predict(X_test)
    print(f"class_weight={weights}: recall {recall_score(y_test, pred):.2f}, precision {precision_score(y_test, pred):.2f}")

proba = LogisticRegression(max_iter=1000).fit(X_train, y_train).predict_proba(X_test)[:, 1]
for threshold in (0.5, 0.2):
    pred = (proba >= threshold).astype(int)
    print(f"threshold {threshold}: recall {recall_score(y_test, pred):.2f}, precision {precision_score(y_test, pred):.2f}")
class_weight=None: recall 0.31, precision 0.78
class_weight=balanced: recall 0.71, precision 0.17
threshold 0.5: recall 0.31, precision 0.78
threshold 0.2: recall 0.49, precision 0.47

Both levers buy recall with precision. Which balance is right is a business decision: how much does a missed case cost compared with a false alarm?

TechniqueHowCaution
Class weightsErrors on the rare class cost more in trainingProbabilities are no longer calibrated
Oversampling or SMOTEMore rare examples, copied or synthesisedTraining data only, or the copies leak into validation
UndersamplingFewer common examplesThrows data away
Threshold tuningFlag at, say, 0.2 instead of 0.5Choose it on validation data, not test data
Better metricsRecall, precision, F1, PR-AUCAccuracy alone hides the problem (question 19)

SMOTE lives in the separate imbalanced-learn package, whose own Pipeline resamples inside each cross-validation fold.

Follow-ups

  • Why resample only the training data? Validation and test sets must reflect the real class balance, or the scores mean nothing.
  • When is imbalance not a problem? When the classes are well separated. Imbalance hurts most when the rare class overlaps the common one.
Q08Not marked yet1–3 yrsAsked oftenScenario

A teammate's first churn model scores 99.5% on validation. What would you check before trusting it?

Say this first~25 sec to say

For churn, that's too good to be true: suspect data leakage. Look for features that reveal the outcome or only exist after it, like an exit-survey flag; preprocessing fitted before the split; the same customer in training and validation; and a random split of time-ordered data instead of validating on later months. Then confirm on a truly held-out later period.

Could you say it?
Full answer3 min read

Leakage you can see in the numbers

Python
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score

rng = np.random.default_rng(0)
n = 2000
df = pd.DataFrame({
    "monthly_spend": rng.normal(800, 200, n),
    "support_tickets": rng.poisson(2, n),
})
df["churned"] = (rng.random(n) < 0.1 + 0.08 * df["support_tickets"].clip(upper=6)).astype(int)
# Filled in by the retention team only AFTER a customer has left
df["exit_survey_done"] = df["churned"] * (rng.random(n) < 0.95)

honest = ["monthly_spend", "support_tickets"]
model = RandomForestClassifier(min_samples_leaf=20, random_state=0)
print(cross_val_score(model, df[honest], df["churned"], cv=5, scoring="roc_auc").mean().round(3))
# 0.629: a modest, believable signal
print(cross_val_score(model, df[honest + ["exit_survey_done"]], df["churned"], cv=5, scoring="roc_auc").mean().round(3))
# 0.973: "excellent", and useless, because the feature doesn't exist at prediction time

The checklist

LeakExampleHow to spot itFix
Target leakageA field filled in after the outcomeOne feature dominates the importancesKeep only what's known at prediction time
Preprocessing leakageScaler or imputer fitted before the splitRead the codePut preprocessing in a pipeline (question 9)
Group leakageThe same customer in train and validationCount IDs shared between the splitsGroupKFold by customer ID
Temporal leakageRandom split of time-ordered dataValidation dates overlap training datesTrain on earlier months, validate on later ones
DuplicatesRepeated rows in both splitsCheck for duplicates firstDeduplicate before splitting

How to say it in the interview: "I'd ask what time each feature becomes available relative to the churn date, look at the feature importances for anything suspiciously strong, and rerun the validation on a later time period."

Follow-ups

  • What does a realistic churn model score? It varies, but ROC-AUC in the 0.70s or 0.80s is common; numbers near 1.0 deserve suspicion.
  • Can leakage happen after deployment? Yes, if a feature is computed differently in production than in training (training–serving skew). Compute features with the same code in both places.
Q09Not marked yet1–3 yrsAsked sometimesCoding

Write one scikit-learn pipeline that imputes and scales numeric columns, one-hot encodes categorical columns and fits a classifier

Say this first~20 sec to say

Use a ColumnTransformer to send numeric columns through a Pipeline of SimpleImputer(strategy="median") and StandardScaler, and categorical columns through SimpleImputer(strategy="most_frequent") and OneHotEncoder(handle_unknown="ignore"). Put that and the classifier into one Pipeline. Then fit learns every statistic from training data only, predict applies exactly the same steps, and passing the pipeline to cross_val_score keeps every fold leak-free.

Could you say it?
Full answer3 min read
Python
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score, train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

# Synthetic customer data with some gaps
rng = np.random.default_rng(42)
n = 1000
df = pd.DataFrame({
    "age": rng.integers(21, 60, n).astype(float),
    "monthly_spend": rng.normal(900, 250, n).round(),
    "city": rng.choice(["Pune", "Delhi", "Bengaluru", "Chennai"], n),
    "plan": rng.choice(["basic", "plus", "pro"], n),
})
df.loc[rng.random(n) < 0.1, "age"] = np.nan
df.loc[rng.random(n) < 0.05, "city"] = np.nan
df["churned"] = (((df["plan"] == "basic") & (df["monthly_spend"] < 900)) | (rng.random(n) < 0.1)).astype(int)

numeric = ["age", "monthly_spend"]
categorical = ["city", "plan"]

preprocess = ColumnTransformer([
    ("num", Pipeline([("impute", SimpleImputer(strategy="median")),
                      ("scale", StandardScaler())]), numeric),
    ("cat", Pipeline([("impute", SimpleImputer(strategy="most_frequent")),
                      ("onehot", OneHotEncoder(handle_unknown="ignore"))]), categorical),
])
model = Pipeline([("prep", preprocess), ("clf", LogisticRegression(max_iter=1000))])

X, y = df[numeric + categorical], df["churned"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)

print(f"CV accuracy: {cross_val_score(model, X_train, y_train, cv=5).mean():.3f}")   # 0.886
model.fit(X_train, y_train)
print(f"test accuracy: {model.score(X_test, y_test):.3f}")                            # 0.875

new_customer = pd.DataFrame([{"age": np.nan, "monthly_spend": 750, "city": "Kochi", "plan": "basic"}])
print(model.predict(new_customer))   # [1]: a missing age and an unseen city are handled
print(model[:-1].get_feature_names_out())
['num__age' 'num__monthly_spend' 'cat__city_Bengaluru' 'cat__city_Chennai'
 'cat__city_Delhi' 'cat__city_Pune' 'cat__plan_basic' 'cat__plan_plus'
 'cat__plan_pro']

Why the pipeline matters

  • No leakage: inside cross_val_score, each fold refits the imputers, scaler and encoder on that fold's training part only.
  • No training–serving skew: the exact same transformations run at prediction time, from one saved object (joblib.dump(model, "churn.joblib")).
  • Tuning everything together: a grid search can tune preprocessing choices too, with names like prep__num__impute__strategy.

Follow-ups

  • What happens to columns you don't list? ColumnTransformer drops them by default (remainder="drop"); pass remainder="passthrough" to keep them.
  • How would you see the transformed data as a DataFrame? Call preprocess.set_output(transform="pandas"). The one-hot encoder must then return a dense array (OneHotEncoder(sparse_output=False)); otherwise scikit-learn raises a ValueError, because pandas output doesn't support sparse data.

03Classic algorithms

0/7 got it

Q10Not marked yetFresherAsked very oftenConcept

What does linear regression assume about the data, and how would you check those assumptions?

Say this first~30 sec to say

Linear regression assumes a linear relationship between the features and the target; independent errors, with no pattern from one row to the next, as often happens in time series; constant error variance across predictions (homoscedasticity); roughly normal residuals, which matters for confidence intervals more than for predictions; and little multicollinearity. Check them with a residuals-vs-fitted plot, which should show no curve and no funnel, a Q–Q plot of the residuals, and correlations or the variance inflation factor (VIF) between features.

Could you say it?
Full answer3 min read
AssumptionIf it's brokenHow to checkCommon fix
LinearitySystematic errorsResiduals vs fitted values show a curveTransform features (log, square), add terms, or use a non-linear model
Independent errorsOverconfident standard errorsResiduals vs time; Durbin–Watson statisticTime-series models, lagged features
Constant varianceUnreliable intervals and p-valuesResiduals fan out as predictions growLog-transform the target, robust standard errors
Normal residualsIntervals and tests are off (predictions are mostly fine)Q–Q plot, histogram of residualsTransform the target; often acceptable with large samples
Low multicollinearityUnstable, hard-to-read coefficientsCorrelation matrix, VIF above about 5 to 10Drop or combine features, or use Ridge

A quick numeric check

Python
import numpy as np
from sklearn.linear_model import LinearRegression

rng = np.random.default_rng(0)
size = rng.uniform(500, 2500, 200)                    # flat size in square feet
price = 3_000 * size + rng.normal(0, 300_000, 200)    # ₹ price: linear, with constant noise

model = LinearRegression().fit(size.reshape(-1, 1), price)
residuals = price - model.predict(size.reshape(-1, 1))
print(round(model.coef_[0]), round(residuals.mean()))   # 2962 0: close to the true ₹3,000 per sq ft

In practice you'd plot residuals against the predictions and look for curves or a funnel shape.

Multicollinearity with VIF

VIF for a feature is 1 / (1 − R²), where R² comes from predicting that feature from all the others:

Python
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression

def vif(df):
    scores = {}
    for col in df.columns:
        others = df.drop(columns=col)
        r2 = LinearRegression().fit(others, df[col]).score(others, df[col])
        scores[col] = round(1 / (1 - r2), 1)
    return scores

rng = np.random.default_rng(1)
area = rng.uniform(400, 3000, 300)
rooms = (area / 450 + rng.normal(0, 0.4, 300)).round()   # mostly area in disguise
age = rng.uniform(0, 30, 300)
print(vif(pd.DataFrame({"area": area, "rooms": rooms, "age": age})))
# {'area': 14.0, 'rooms': 14.0, 'age': 1.0}

area and rooms carry nearly the same information, so the model can't tell how much credit each deserves.

Follow-ups

  • Does multicollinearity hurt predictions? Usually not much; it mainly makes individual coefficients unstable and hard to interpret.
  • How is the model fitted? By ordinary least squares: choosing the weights that minimise the sum of squared residuals.
Q11Not marked yetFresherAsked very oftenConcept

Logistic regression is a classifier, so why is it called regression, and how does it produce a class label?

Say this first~25 sec to say

It's a linear regression on the log-odds of the positive class. The model computes a linear score, z = w·x + b, and passes it through the sigmoid, 1 / (1 + e⁻ᶻ), which squashes any number into a probability between 0 and 1. A threshold, 0.5 by default, turns that probability into a class. It's trained by minimising log loss (cross-entropy), which punishes confident wrong answers heavily.

Could you say it?
Full answer2 min read
Python
import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

def sigmoid(z):
    return 1 / (1 + np.exp(-z))

print(sigmoid(np.array([-4, 0, 4])).round(3))   # [0.018 0.5   0.982]

X, y = load_breast_cancer(return_X_y=True)
model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)).fit(X, y)

rows = [29, 215, 542, 224]
proba = model.predict_proba(X[rows])[:, 1]
print(proba.round(3), model.predict(X[rows]))   # [0.021 0.407 0.704 0.98 ] [0 0 1 1]

z = model.decision_function(X[rows])            # the linear score w·x + b
print(np.allclose(sigmoid(z), proba))           # True: probability = sigmoid(linear score)

Odds and log-odds

  • A probability of 0.8 means odds of 0.8 / 0.2 = 4 to 1, and log-odds of ln 4 ≈ 1.39.
  • The model says log-odds = w·x + b. So a coefficient of 0.7 means one more unit of that feature multiplies the odds by e^0.7 ≈ 2.

Why not mean squared error? With a sigmoid in the model, squared error gives a bumpy loss surface with weak gradients when the model is confidently wrong. Log loss is convex for logistic regression, so gradient descent reliably finds the best weights.

Follow-ups

  • More than two classes? Either one-vs-rest (one binary model per class), or multinomial logistic regression with softmax (question 30), which scikit-learn uses by default.
  • Why move the threshold? 0.5 isn't sacred. If missing a positive is costly, a lower threshold catches more of them (question 7).
  • Is it a linear model? Yes: its decision boundary, where the probability is 0.5, is a straight line (a hyperplane) in feature space.
Q12Not marked yetFresherAsked very oftenConcept

How does a decision tree decide where to split, and how do Gini impurity and entropy compare?

Say this first~30 sec to say

A tree grows greedily: at each node it tries every feature and threshold and keeps the split that leaves the children purest. Gini, 1 − Σp², is the chance two random samples disagree; entropy, −Σp log₂ p, measures uncertainty, and its drop is information gain. Both are 0 when pure and peak at 50/50, so they nearly always agree; Gini is slightly cheaper. Without limits on depth, a tree overfits.

Could you say it?
Full answer2 min read
Python
import numpy as np

def gini(counts):
    p = np.array(counts) / sum(counts)
    return 1 - np.sum(p ** 2)

def entropy(counts):
    p = np.array(counts) / sum(counts)
    p = p[p > 0]                          # 0 × log(0) counts as 0
    return np.sum(p * np.log2(1 / p))

for counts in ([10, 0], [5, 5], [8, 2]):
    print(counts, f"gini {gini(counts):.2f}", f"entropy {entropy(counts):.2f}")
[10, 0] gini 0.00 entropy 0.00
[5, 5] gini 0.50 entropy 1.00
[8, 2] gini 0.32 entropy 0.72

Scoring one split

A node with 5 positives and 5 negatives (Gini 0.5) splits into [4, 0] and [1, 5]. The children's Gini, weighted by size, is 0.4 × 0 + 0.6 × 0.28 ≈ 0.17, so the split reduces impurity by about 0.33. The tree keeps the candidate split with the biggest reduction.

What a small tree learns

Python
from sklearn.datasets import load_iris
from sklearn.tree import DecisionTreeClassifier, export_text

iris = load_iris()
tree = DecisionTreeClassifier(max_depth=2, random_state=0).fit(iris.data, iris.target)
print(export_text(tree, feature_names=iris.feature_names))
|--- petal width (cm) <= 0.80
|   |--- class: 0
|--- petal width (cm) >  0.80
|   |--- petal width (cm) <= 1.75
|   |   |--- class: 1
|   |--- petal width (cm) >  1.75
|   |   |--- class: 2

Stopping it from overfitting: max_depth, min_samples_leaf and min_samples_split stop growth early (pre-pruning); ccp_alpha grows the full tree and then cuts back branches that add little (cost-complexity pruning).

Follow-ups

  • How does a regression tree split? By the drop in variance (squared error) of the target, and each leaf predicts the mean of its samples.
  • Pros and cons of a single tree? Easy to explain, no scaling needed, captures interactions. But it's unstable: a small change in the data can produce a very different tree, which ensembles fix (question 13).
Q13Not marked yetFresherAsked very oftenConcept

How do bagging and boosting differ? Use random forest and gradient boosting as examples.

Say this first~25 sec to say

Bagging trains many models independently on bootstrap samples and averages them, mainly cutting variance. A random forest is bagged trees that also sample features at each split, so their errors differ and cancel out. Boosting trains models in sequence, each fixing the errors left by the ones before, mainly cutting bias; gradient boosting fits each new tree to the remaining errors. XGBoost and LightGBM are optimised gradient boosting.

Could you say it?
Full answer2 min read
Python
from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import GradientBoostingClassifier, RandomForestClassifier
from sklearn.model_selection import cross_val_score
from sklearn.tree import DecisionTreeClassifier

X, y = load_breast_cancer(return_X_y=True)
for name, model in [
    ("single tree", DecisionTreeClassifier(random_state=0)),
    ("random forest", RandomForestClassifier(n_estimators=300, random_state=0)),
    ("gradient boosting", GradientBoostingClassifier(random_state=0)),
]:
    print(f"{name:18} {cross_val_score(model, X, y, cv=5).mean():.3f}")
single tree        0.917
random forest      0.963
gradient boosting  0.963
Bagging (random forest)Boosting (gradient boosting)
How models are builtIn parallel, independentlyIn sequence, each fixing the last
Each model seesA bootstrap sample of the rowsAll the rows, with the current errors as its target
Mainly reducesVarianceBias
Typical base modelDeep, overfitting treesShallow trees ("weak learners")
Overfitting riskLow; more trees don't hurtHigher; needs a small learning rate and early stopping
Tuning effortLittleMore: learning rate, number of trees, depth

Why a random forest beats a single tree: one deep tree overfits in its own particular way. Hundreds of trees trained on different samples, with different features at each split, make different mistakes, and averaging cancels much of the noise while keeping the signal.

Follow-ups

  • What makes XGBoost fast and accurate? Regularisation in the tree-building objective, second-order gradient information, efficient split finding, built-in handling of missing values, and parallel computation.
  • What is out-of-bag error? Each tree never sees about a third of the rows, so those rows give a free validation estimate (oob_score=True).
  • What is stacking? A third ensemble style: a second-level model learns how best to combine the predictions of several different models.
Q14Not marked yetFresherAsked very oftenConcept

Walk through how k-means clustering works. How would you choose the number of clusters?

Say this first~25 sec to say

Pick k starting centroids, then repeat until nothing changes: assign each point to its nearest centroid, then move each centroid to the mean of its points. Each round lowers the within-cluster squared distance (inertia), but it can stop at a local optimum, so it's restarted several times. Choose k with the elbow method or the silhouette score, plus business sense, and scale features first.

Could you say it?
Full answer3 min read
Python
from sklearn.cluster import KMeans
from sklearn.datasets import make_blobs
from sklearn.metrics import silhouette_score

X, _ = make_blobs(n_samples=500, centers=4, cluster_std=1.0, random_state=7)

for k in range(2, 7):
    km = KMeans(n_clusters=k, n_init=10, random_state=7).fit(X)
    print(k, round(km.inertia_), round(silhouette_score(X, km.labels_), 3))
2 15172 0.588
3 4313 0.763
4 920 0.811
5 818 0.694
6 732 0.576

Inertia always falls as k grows, but the big drops stop after k = 4 (the elbow), and the silhouette score, which rewards tight, well-separated clusters, peaks at 4 too.

The algorithm in NumPy

Python
import numpy as np

def kmeans(X, k, iterations=100, seed=0):
    rng = np.random.default_rng(seed)
    centroids = X[rng.choice(len(X), k, replace=False)]        # k random points to start
    for _ in range(iterations):
        distances = ((X[:, None, :] - centroids[None, :, :]) ** 2).sum(axis=2)
        labels = distances.argmin(axis=1)                       # 1. assign to the nearest centroid
        new = np.array([X[labels == j].mean(axis=0) for j in range(k)])   # 2. move to the mean
        if np.allclose(new, centroids):                         # nothing moved: converged
            break
        centroids = new
    return labels, centroids

labels, centroids = kmeans(X, 4)
print(np.bincount(labels))   # [125 125 125 125]: the points in each cluster

KNN vs k-means, a common follow-up

KNNk-means
TypeSupervised classification or regressionUnsupervised clustering
What k meansThe number of neighbours that voteThe number of clusters
TrainingNone: it stores the dataIterative centroid updates

Limitations: k-means assumes roughly round clusters of similar size, is pulled around by outliers, and needs k chosen in advance. DBSCAN finds clusters of any shape and labels noise; Gaussian mixture models allow elongated clusters with soft membership.

Follow-ups

  • Why scale first? Distances are dominated by features measured in big units (question 6).
  • What does k-means++ do? It picks each new starting centroid with probability proportional to its squared distance from the existing ones, so the starts are spread out and convergence is faster and better.
Q15Not marked yetFresherAsked oftenCoding

With scikit-learn, split a dataset into train and test sets, fit a logistic regression and print test accuracy and a classification report

Say this first~25 sec to say

Call train_test_split with a fixed random_state and stratify=y, so both sets keep the class ratio. Fit a LogisticRegression, inside a pipeline with StandardScaler, on the training set only. Predict on the test set, then print accuracy_score and classification_report, which gives precision, recall and F1 for each class. Fitting anything, the scaler included, on test data would leak information and inflate the score.

Could you say it?
Full answer2 min read
Python
from sklearn.datasets import load_breast_cancer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, classification_report
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

data = load_breast_cancer()
X_train, X_test, y_train, y_test = train_test_split(
    data.data, data.target, test_size=0.25, stratify=data.target, random_state=42
)

model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))
model.fit(X_train, y_train)               # learns from the training data only
y_pred = model.predict(X_test)

print(f"accuracy: {accuracy_score(y_test, y_pred):.3f}")
print(classification_report(y_test, y_pred, target_names=data.target_names))
accuracy: 0.986
              precision    recall  f1-score   support

   malignant       0.98      0.98      0.98        53
      benign       0.99      0.99      0.99        90

    accuracy                           0.99       143
   macro avg       0.99      0.99      0.99       143
weighted avg       0.99      0.99      0.99       143

Reading the report

  • precision: of the cases predicted as this class, the share that were right.
  • recall: of the real cases of this class, the share the model found.
  • f1-score: the harmonic mean of the two (question 18).
  • support: how many test cases belong to the class.
  • macro avg treats every class equally; weighted avg weights each class by its support.

Mistakes interviewers look for

  • Scaling or encoding before splitting, so the test set influences training.
  • Forgetting stratify, so a small test set ends up with too few of one class.
  • Reporting only accuracy on imbalanced data (question 19).
  • max_iter too low, leaving a ConvergenceWarning unexplained.

Follow-ups

  • Why random_state? It makes the split reproducible, so results can be compared and rerun.
  • Is one split enough? For a final check, yes; for choosing between models, cross-validation is more reliable (question 3).
Q16Not marked yet1–3 yrsAsked sometimesScenario

A manager asks which inputs drive your churn model's predictions. How would you find out and explain it?

Say this first~30 sec to say

Start with the whole model, then individual customers. For a linear model on standardised features, the coefficients show direction and strength. For trees, built-in impurity importance is quick but biased towards high-cardinality features, so prefer permutation importance: shuffle one feature on validation data and measure how much the score drops. SHAP values explain single predictions, showing how each feature pushed one customer's score up or down. And warn that correlated features share credit, and importance isn't causation.

Could you say it?
Full answer3 min read
Python
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.inspection import permutation_importance
from sklearn.model_selection import train_test_split

rng = np.random.default_rng(3)
n = 3000
df = pd.DataFrame({
    "support_tickets": rng.poisson(2, n),
    "monthly_spend": rng.normal(900, 250, n),          # noise: unrelated to churn here
    "tenure_months": rng.integers(1, 60, n),
    "customer_id_digits": rng.integers(0, 10_000, n),  # pure noise with many distinct values
})
logit = 0.6 * df["support_tickets"] - 0.05 * df["tenure_months"] - 1
df["churned"] = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)

X, y = df.drop(columns="churned"), df["churned"]
X_train, X_val, y_train, y_val = train_test_split(X, y, random_state=3)
model = RandomForestClassifier(n_estimators=200, random_state=3).fit(X_train, y_train)

print(pd.Series(model.feature_importances_, index=X.columns).round(3).sort_values(ascending=False))
perm = permutation_importance(model, X_val, y_val, n_repeats=10, random_state=3)
print(pd.Series(perm.importances_mean, index=X.columns).round(3).sort_values(ascending=False))
customer_id_digits    0.292
monthly_spend         0.289
tenure_months         0.267
support_tickets       0.152
dtype: float64
support_tickets       0.056
tenure_months         0.046
customer_id_digits   -0.000
monthly_spend        -0.001
dtype: float64

The built-in importance ranks the two noise columns highest, because columns with many distinct values give a tree endless split points to overfit with. Permutation importance on held-out data gets it right: only tickets and tenure matter.

MethodExplainsStrengthCaveat
CoefficientsThe whole linear modelDirection and size per unit changeScale features first; misleading with correlated features
Impurity importanceThe whole tree modelFree, instantInflated for high-cardinality and noisy features
Permutation importanceAny model, overallMeasured on held-out dataCorrelated features can hide each other
SHAP valuesEach single predictionConsistent, adds up to the predictionSlower; still not causal
Partial dependence plotsOne feature's average effectShows the shape of the effectAverages can hide interactions

Explaining it to the manager: lead with plain language ("customers who raise more support tickets in their first months are the most likely to leave"), show one or two concrete customer examples, and be explicit that this shows what the model uses, not what causes churn. To act on it, test an intervention, such as faster ticket resolution, and measure the effect.

Follow-ups

  • Why measure permutation importance on validation data? On training data it also rewards features the model merely memorised.
  • What if two features are strongly correlated? Shuffling one barely hurts, because the model leans on the other, so both look unimportant. Group them, or drop one, before interpreting.

04Evaluation metrics

0/5 got it

Q17Not marked yetFresherAsked very oftenConcept

What does a confusion matrix show? Explain TP, FP, TN and FN using a disease-test example.

Say this first~30 sec to say

A confusion matrix is a table of actual classes against predicted classes. For a disease test: true positives are sick people the test flags; false positives are healthy people it wrongly flags, a false alarm; true negatives are healthy people it clears; false negatives are sick people it misses. Every classification metric, accuracy, precision, recall, specificity and F1, comes from these four counts, and false positives and false negatives usually cost very different amounts.

Could you say it?
Full answer2 min read
Predicted: sickPredicted: healthy
Actually sickTrue positive (TP): caughtFalse negative (FN): missed
Actually healthyFalse positive (FP): false alarmTrue negative (TN): correctly cleared
Python
from sklearn.metrics import confusion_matrix

actual    = [1, 1, 1, 1, 0, 0, 0, 0, 0, 0]   # 1 = has the disease
predicted = [1, 1, 1, 0, 1, 0, 0, 0, 0, 0]

print(confusion_matrix(actual, predicted))
# [[5 1]
#  [1 3]]
tn, fp, fn, tp = confusion_matrix(actual, predicted).ravel()
print(f"TP={tp} FP={fp} TN={tn} FN={fn}")    # TP=3 FP=1 TN=5 FN=1

scikit-learn puts the actual classes in rows and the predicted ones in columns, sorted by label, so the negative class (0) comes first. Check the layout of whatever tool you use; textbooks often draw it the other way round.

The metrics, from those four numbers

MetricFormulaHerePlain meaning
Accuracy(TP + TN) / all8 / 10 = 0.80Share of all predictions that were right
PrecisionTP / (TP + FP)3 / 4 = 0.75When it says "sick", how often it's right
Recall (sensitivity)TP / (TP + FN)3 / 4 = 0.75Share of sick people it catches
SpecificityTN / (TN + FP)5 / 6 ≈ 0.83Share of healthy people it clears

Which mistake is worse? For a screening test, a false negative sends a sick person home, while a false positive costs a follow-up test. So screening favours recall, accepting more false alarms (question 18).

Follow-ups

  • What about more than two classes? The matrix becomes n × n: each row is a true class, each column a predicted class, and the off-diagonal cells show exactly which classes get confused.
  • What are Type I and Type II errors? A false positive and a false negative, in statistics language.
Q18Not marked yetFresherAsked very oftenConcept

Explain precision, recall and F1-score. Give one case where you'd favour precision and one where you'd favour recall.

Say this first~30 sec to say

Precision = TP / (TP + FP): of everything flagged, how much was really positive. Recall = TP / (TP + FN): of all real positives, how many were caught. F1 is their harmonic mean, high only when both are. Favour precision when false alarms hurt, like a spam filter binning real email; favour recall when misses hurt, like disease or fraud screening, where follow-up checks clear false alarms.

Could you say it?
Full answer2 min read
Python
import numpy as np
from sklearn.metrics import f1_score, precision_score, recall_score

actual    = np.array([1, 1, 1, 1, 0, 0, 0, 0, 0, 0])
predicted = np.array([1, 1, 1, 0, 1, 0, 0, 0, 0, 0])
print(precision_score(actual, predicted), recall_score(actual, predicted), f1_score(actual, predicted))
# 0.75 0.75 0.75

The threshold trades one for the other

Python
scores = np.array([0.95, 0.85, 0.7, 0.45, 0.6, 0.3, 0.2, 0.15, 0.1, 0.05])   # model probabilities
for threshold in (0.8, 0.5, 0.25):
    pred = (scores >= threshold).astype(int)
    p, r = precision_score(actual, pred), recall_score(actual, pred)
    print(f"threshold {threshold}: precision {p:.2f}, recall {r:.2f}")
threshold 0.8: precision 1.00, recall 0.50
threshold 0.5: precision 0.75, recall 0.75
threshold 0.25: precision 0.67, recall 1.00

A strict threshold flags only the surest cases: precise but with many misses. A lenient one catches everything but raises more false alarms.

Why a harmonic mean? It punishes imbalance. A model with precision 1.0 and recall 0.1 has an ordinary average of 0.55, but an F1 of only 0.18, which better reflects how useless it is.

SituationFavourWhy
Spam filterPrecisionLosing a real email is worse than seeing some spam
Disease screeningRecallMissing a sick patient is worse than a follow-up test
Fraud alertsRecall, within the team's review capacityMissed fraud costs money; analysts can review alerts
Recommending productsPrecision at the topUsers only see the first few results

Follow-ups

  • What is the F-beta score? A weighted version: F2 counts recall twice as much as precision, F0.5 the reverse.
  • Can you have high precision and high recall at once? Only if the model separates the classes well. Otherwise you pick a point on the trade-off curve.
Q19Not marked yetFresherAsked very oftenScenario

Only 1 in 100 transactions in your data is fraud, and your model reports 99% accuracy. Should the team be pleased?

Say this first~30 sec to say

Not yet. A model that says "not fraud" for every transaction also scores 99%, while catching zero fraud. That's the accuracy paradox: with heavy imbalance, accuracy mostly measures the majority class. Look at the confusion matrix, the fraud class's recall (how much fraud is caught) and precision (how many alerts are real), and PR-AUC, and compare against that do-nothing baseline. Then weigh the cost of missed fraud against the cost of reviewing false alarms.

Could you say it?
Full answer2 min read
Python
from sklearn.datasets import make_classification
from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, average_precision_score, precision_score, recall_score
from sklearn.model_selection import train_test_split

X, y = make_classification(n_samples=20_000, weights=[0.99], flip_y=0, class_sep=0.8, random_state=7)
X_train, X_test, y_train, y_test = train_test_split(X, y, stratify=y, test_size=0.3, random_state=7)

baseline = DummyClassifier(strategy="most_frequent").fit(X_train, y_train)
model = LogisticRegression(class_weight="balanced", max_iter=1000).fit(X_train, y_train)

for name, clf in [("always 'not fraud'", baseline), ("logistic regression", model)]:
    pred = clf.predict(X_test)
    print(f"{name:20} accuracy {accuracy_score(y_test, pred):.3f}  "
          f"recall {recall_score(y_test, pred):.2f}  precision {precision_score(y_test, pred, zero_division=0):.2f}")
print(f"PR-AUC {average_precision_score(y_test, model.predict_proba(X_test)[:, 1]):.2f} (a random model scores about {y_test.mean():.2f})")
always 'not fraud'   accuracy 0.990  recall 0.00  precision 0.00
logistic regression  accuracy 0.946  recall 0.92  precision 0.15
PR-AUC 0.78 (a random model scores about 0.01)

The useful model has lower accuracy than doing nothing, but it catches 92% of fraud. Its precision of 0.15 means about one alert in seven is real, which may or may not be affordable for the review team; the threshold can be tuned to suit.

What to report instead of accuracy

  • The confusion matrix, in counts the business understands. For this model: "of 60 frauds, we caught 55, and raised 377 alerts, 322 of them false".
  • Recall and precision for the fraud class, at the chosen threshold.
  • PR-AUC, to compare models across all thresholds. Its baseline is the fraud rate, not 0.5.
  • The money: fraud caught × average loss, minus alerts × review cost.

Follow-ups

  • Why PR-AUC rather than ROC-AUC here? ROC's false positive rate divides by the huge number of genuine transactions, so it stays tiny even when false alarms swamp the real frauds. Precision exposes that directly.
  • How would you improve the model? Better features (velocity, device, merchant history), class weights or resampling (question 7), and a threshold chosen from the cost trade-off.
Q20Not marked yetFresherAsked oftenConcept

What does a ROC curve plot, and what does the area under it tell you?

Say this first~30 sec to say

A ROC curve plots the true positive rate (recall) against the false positive rate (false alarms ÷ all actual negatives) as the decision threshold slides from 1 down to 0. The area under the curve (AUC) is the probability that the model scores a random positive above a random negative: 0.5 is random guessing, 1.0 is perfect ranking. It summarises performance across every threshold without choosing one. With heavy class imbalance, the precision–recall curve is more informative.

Could you say it?
Full answer2 min read
Python
import numpy as np
from sklearn.metrics import roc_auc_score, roc_curve

actual = np.array([1, 1, 1, 1, 0, 0, 0, 0, 0, 0])
scores = np.array([0.95, 0.85, 0.7, 0.45, 0.6, 0.3, 0.2, 0.15, 0.1, 0.05])

fpr, tpr, thresholds = roc_curve(actual, scores)
for f, t, th in zip(fpr, tpr, thresholds):
    print(f"threshold {th:>4}: TPR {t:.2f}, FPR {f:.2f}")
print("AUC:", roc_auc_score(actual, scores))
threshold  inf: TPR 0.00, FPR 0.00
threshold 0.95: TPR 0.25, FPR 0.00
threshold  0.7: TPR 0.75, FPR 0.00
threshold  0.6: TPR 0.75, FPR 0.17
threshold 0.45: TPR 1.00, FPR 0.17
threshold 0.05: TPR 1.00, FPR 1.00
AUC: 0.9583333333333334

AUC really is a ranking probability

Python
positives, negatives = scores[actual == 1], scores[actual == 0]
print(np.mean([p > n for p in positives for n in negatives]))   # 0.9583333333333334: the same number

Of the 24 positive–negative pairs, the model ranks the positive higher in 23. The one miss: the positive scored 0.45 sits below a negative scored 0.6.

AUCMeaning
1.0Every positive is ranked above every negative
0.9 and upExcellent separation
0.7 to 0.9Useful
0.5No better than a coin
Below 0.5Worse than random: the predictions are probably inverted

Follow-ups

  • Does a high AUC mean good probabilities? No. AUC only measures ranking. A model can rank perfectly but output badly calibrated probabilities; check a calibration curve if the numbers themselves matter.
  • How do you choose the operating threshold? From the costs of each error, or a required recall or precision, using validation data.
Q21Not marked yetFresherAsked oftenConcept

How do MAE, RMSE and R² differ, and when would you prefer each for a regression model?

Say this first~30 sec to say

MAE averages the absolute errors: simple, in the target's units, and every unit of error counts equally. RMSE squares the errors first, so it's also in the target's units but punishes big misses far more; prefer it when large errors are especially bad. R² is the share of variance explained: 1 is perfect, 0 matches predicting the mean. It never drops when you add features, which adjusted R² fixes.

Could you say it?
Full answer2 min read
Python
import numpy as np
from sklearn.metrics import mean_absolute_error, r2_score, root_mean_squared_error

actual = np.array([50, 60, 70, 80, 90])     # delivery times in minutes
model_a = np.array([52, 58, 73, 78, 92])    # small errors everywhere
model_b = np.array([50, 60, 70, 80, 70])    # perfect, except one 20-minute miss

for name, pred in [("A", model_a), ("B", model_b)]:
    mae = mean_absolute_error(actual, pred)
    rmse = root_mean_squared_error(actual, pred)
    print(f"{name}: MAE {mae:.1f}  RMSE {rmse:.1f}  R² {r2_score(actual, pred):.3f}")
A: MAE 2.2  RMSE 2.2  R² 0.975
B: MAE 4.0  RMSE 8.9  R² 0.600

Model B's MAE is less than twice A's, but its RMSE is four times bigger: one big miss dominates a squared metric.

MetricUnitsSensitive to outliersBest when
MAEThe target'sLowEvery unit of error costs the same; you need to explain it simply
RMSEThe target'sHighLarge errors are disproportionately bad
R²None (a share)MediumComparing how much variation different models explain
MAPEPercentHigh near zeroErrors relative to size matter, and the target is never near 0

Adjusted R² = 1 − (1 − R²)(n − 1) / (n − p − 1), for n rows and p features. It only rises if a new feature improves the fit more than chance would.

Follow-ups

  • Can R² be negative? Yes, on new data, when the model is worse than always predicting the mean.
  • Which would you report to a business team? Usually MAE, in their units: "on average we're 2 minutes off".

05Training and tuning

0/3 got it

Q22Not marked yetFresherAsked very oftenConcept

What is gradient descent, and what happens when the learning rate is too high or too low?

Say this first~30 sec to say

Gradient descent minimises the loss by repeatedly stepping downhill: compute the gradient, the slope of the loss with respect to each weight, then update every weight by subtracting learning rate × gradient. If the learning rate is too high, steps overshoot the minimum, bounce around or diverge to infinity. If it's too low, training crawls and can stall. Batch gradient descent uses all the data per step, stochastic uses one example, and mini-batch, the usual choice, uses small batches.

Could you say it?
Full answer2 min read
Python
import numpy as np

rng = np.random.default_rng(0)
x = rng.uniform(0, 10, 100)
y = 3 * x + 5 + rng.normal(0, 1, 100)       # the true line: slope 3, intercept 5

def train(lr, steps=1000):
    w, b = 0.0, 0.0
    for _ in range(steps):
        error = w * x + b - y
        w -= lr * 2 * np.mean(error * x)     # the gradient of mean squared error with respect to w
        b -= lr * 2 * np.mean(error)         # ... and with respect to b
    return w, b, np.mean((w * x + b - y) ** 2)

for lr in (0.0001, 0.01, 0.05):
    with np.errstate(over="ignore", invalid="ignore"):   # the large rate overflows
        w, b, mse = train(lr)
    print(f"lr={lr}: w={w:.2f}, b={b:.2f}, MSE={mse:.3g}")
lr=0.0001: w=3.59, b=0.71, MSE=5.23
lr=0.01: w=2.99, b=4.95, MSE=0.941
lr=0.05: w=nan, b=nan, MSE=nan

At 0.0001 it's still far from the answer after 1,000 steps. At 0.01 it finds the true line. At 0.05 every step overshoots further than the last, and the weights explode.

VariantData per updateTrade-off
BatchThe whole datasetSmooth but slow, and needs it all in memory
Stochastic (SGD)One exampleFast, noisy updates; the noise can help escape poor spots
Mini-batch32 to 256 examples, typicallyThe usual compromise, and efficient on GPUs

In practice: optimisers such as momentum and Adam adapt the step size per weight and smooth out the noise. A learning-rate schedule (warm-up, then decay) is standard for deep networks, and scaling features helps every variant converge (question 6).

Follow-ups

  • What is an epoch? One full pass over the training data: with 10,000 rows and batches of 100, an epoch is 100 updates.
  • Does it always find the global minimum? For convex losses such as linear and logistic regression, yes, with a suitable learning rate. Neural network losses aren't convex, but in practice they reach good minima.
Q23Not marked yetFresherAsked very oftenConcept

How do L1 (Lasso) and L2 (Ridge) regularisation differ, and when would you choose L1?

Say this first~25 sec to say

Both penalise large weights, trading a little training fit for a simpler model. L1 (Lasso) adds the sum of absolute weights and drives many weights to exactly zero, which selects features. L2 (Ridge) adds the sum of squared weights; it shrinks all weights smoothly and shares weight between correlated features. Choose L1 when only a few features should matter, or you want a sparse, readable model.

Could you say it?
Full answer2 min read
Python
import numpy as np
from sklearn.datasets import make_regression
from sklearn.linear_model import Lasso, LinearRegression, Ridge

# 20 features, only 5 of which actually affect the target
X, y = make_regression(n_samples=200, n_features=20, n_informative=5, noise=10, random_state=0)

for name, model in [("plain", LinearRegression()), ("ridge (L2)", Ridge(alpha=10)), ("lasso (L1)", Lasso(alpha=1))]:
    coef = model.fit(X, y).coef_
    print(f"{name:11} zero weights: {np.sum(np.abs(coef) < 1e-6):2}   largest: {np.abs(coef).max():.1f}")
plain       zero weights:  0   largest: 83.8
ridge (L2)  zero weights:  0   largest: 79.0
lasso (L1)  zero weights: 12   largest: 82.5

Lasso switched off 12 of the 15 useless features. Ridge kept all 20, only slightly smaller.

L1 (Lasso)L2 (Ridge)
Penaltyλ × Σ|w|λ × Σw²
Effect on weightsMany become exactly 0All shrink, rarely to 0
Correlated featuresTends to keep one and drop the restSpreads the weight across them
Use whenYou want feature selection or a sparse, readable modelMany features each matter a little

Elastic Net combines both penalties, which helps when there are groups of correlated features.

Practical notes: the strength λ is alpha in scikit-learn's Ridge and Lasso, and should be tuned with cross-validation (LassoCV, RidgeCV). In LogisticRegression, C is the inverse strength, so a smaller C means stronger regularisation. Always scale features first, so the penalty treats them fairly.

Follow-ups

  • Why does L1 give exact zeros? Its penalty has a corner at zero, and the optimum often lands right on it. The L2 penalty is smooth there, so weights approach zero without reaching it.
  • Is dropout a form of regularisation? Yes: in neural networks, randomly switching off units during training stops the network relying on any single unit.
Q24Not marked yet1–3 yrsAsked sometimesCoding

Tune a random forest's depth and number of trees with cross-validated grid search in scikit-learn, then score the best model on held-out test data

Say this first~25 sec to say

Hyperparameters like max_depth and n_estimators are set before training. Hold out a test set first. GridSearchCV then trains and scores every combination with k-fold CV on the training data, picks the best mean score and refits it on all the training data. Score that model once on the test set. RandomizedSearchCV samples a fixed number of combinations: far cheaper for big search spaces.

Could you say it?
Full answer3 min read
Python
from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import GridSearchCV, train_test_split

X, y = load_breast_cancer(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=0)

grid = GridSearchCV(
    RandomForestClassifier(random_state=0),
    param_grid={"max_depth": [3, 6, None], "n_estimators": [100, 300]},
    cv=5,
    scoring="f1",
    n_jobs=-1,                                   # use every CPU core
)
grid.fit(X_train, y_train)                       # 3 × 2 combinations × 5 folds = 30 fits
print(grid.best_params_, f"CV F1 {grid.best_score_:.3f}")
# {'max_depth': None, 'n_estimators': 300} CV F1 0.976
print(f"test F1 {grid.score(X_test, y_test):.3f}")   # test F1 0.958: the held-out set, used once

Random search for bigger spaces

Python
from scipy.stats import randint
from sklearn.model_selection import RandomizedSearchCV

search = RandomizedSearchCV(
    RandomForestClassifier(random_state=0),
    param_distributions={
        "max_depth": randint(2, 20),
        "n_estimators": randint(50, 400),
        "min_samples_leaf": randint(1, 10),
    },
    n_iter=10,                                   # 10 random combinations, not all of them
    cv=5,
    scoring="f1",
    random_state=0,
    n_jobs=-1,
)
search.fit(X_train, y_train)
print(sorted(search.best_params_))   # ['max_depth', 'min_samples_leaf', 'n_estimators']
Grid searchRandom searchBayesian optimisation (Optuna and similar)
TriesEvery combinationA fixed number of random onesPromising combinations, guided by earlier results
CostGrows multiplicatively with each new parameterYou set itYou set it; usually the fewest trials
Best forA few parameters with a few valuesMany parameters, some unimportantExpensive models

Parameters vs hyperparameters: parameters are learned from the data (a regression's weights, a tree's split thresholds). Hyperparameters are chosen by you before training (tree depth, learning rate, regularisation strength).

Follow-ups

  • Why not tune on the test set? The test score would no longer be an honest estimate: you'd have chosen the model that happened to suit those particular rows.
  • What does refit=True do? After the search, it retrains the best combination on all the training data, so grid.predict uses that final model.

06pandas and NumPy

0/5 got it

Q25Not marked yetFresherAsked very oftenConcept

In pandas, how do loc and iloc differ when selecting rows and columns?

Say this first~30 sec to say

loc selects by label: index labels, column names or a boolean mask, and its slices include the end. iloc selects by integer position, like a list, and its slices exclude the end. They agree only while the index is 0, 1, 2 and so on; after filtering or sorting, labels and positions drift apart, which is where bugs come from. Use loc for conditions and names, iloc for positions.

Could you say it?
Full answer2 min read
Python
import pandas as pd

df = pd.DataFrame(
    {"name": ["Asha", "Ravi", "Priya", "Arjun"],
     "city": ["Pune", "Delhi", "Pune", "Chennai"],
     "salary": [72, 55, 91, 64]},              # ₹ thousands a month
    index=[101, 102, 103, 104],                # employee ids as the index
)

print(df.loc[102, "name"])                     # Ravi: by label
print(df.iloc[1, 0])                           # Ravi: by position
print(df.loc[101:103, "name"].tolist())        # ['Asha', 'Ravi', 'Priya']: end label included
print(df.iloc[0:2, 0].tolist())                # ['Asha', 'Ravi']: end position excluded
print(df.loc[df["city"] == "Pune", ["name", "salary"]])
      name  salary
101   Asha      72
103  Priya      91

Where they drift apart

Python
by_salary = df.sort_values("salary")
print(by_salary.loc[101, "name"])        # Asha: the label still points to Asha
print(by_salary.iloc[0]["name"])         # Ravi: position 0 is now the lowest salary

Changing values: always one loc call

Python
df.loc[df["city"] == "Pune", "salary"] += 5    # works: one call selects rows and column together
print(df["salary"].tolist())                   # [77, 55, 96, 64]

df[df["city"] == "Pune"]["salary"] = 0         # chained: changes a temporary copy, never df
print(df["salary"].tolist())                   # [77, 55, 96, 64]: unchanged

In pandas 3, the chained version also prints a ChainedAssignmentError warning: under copy-on-write, the first [...] always returns a new object, so assigning into it can't affect df.

lociloc
Selects byLabels, names, boolean masksInteger positions
Slice endIncludedExcluded
df.xxx[0]The row whose label is 0, if there is oneThe first row
For one value, fasterdf.at[label, col]df.iat[i, j]

Follow-ups

  • What does df["col"] select? A column by name. df[0:2] slices rows by position: a confusing shortcut, so prefer iloc for clarity.
  • How do you reset the index after filtering? df.reset_index(drop=True) renumbers the rows from 0.
Q26Not marked yetFresherAsked very oftenCoding

From an orders DataFrame with city, product and revenue columns, find the product with the highest total revenue in each city

Say this first~25 sec to say

First group by city and product and sum the revenue, so each city–product pair has one total. Then, within each city, find the row with the largest total: groupby("city")["revenue"].idxmax() returns those row labels, and .loc fetches the rows. An equivalent route is to sort by revenue descending and keep the first row per city with drop_duplicates("city"). Ask how ties should be handled.

Could you say it?
Full answer2 min read
Python
import pandas as pd

orders = pd.DataFrame({
    "city": ["Pune", "Pune", "Pune", "Delhi", "Delhi", "Delhi", "Chennai"],
    "product": ["Kettle", "Mixer", "Kettle", "Mixer", "Mixer", "Kettle", "Mixer"],
    "revenue": [1200, 3000, 2500, 3000, 1500, 4000, 2200],
})

totals = orders.groupby(["city", "product"], as_index=False)["revenue"].sum()
best = totals.loc[totals.groupby("city")["revenue"].idxmax()]
print(best)
      city product  revenue
0  Chennai   Mixer     2200
2    Delhi   Mixer     4500
3     Pune  Kettle     3700

Summing first matters: Delhi's single biggest order is a ₹4,000 kettle, but its mixers total ₹4,500.

The sort-and-deduplicate route

Python
best = (totals.sort_values("revenue", ascending=False)
              .drop_duplicates("city")
              .sort_values("city"))
print(best["product"].tolist())   # ['Mixer', 'Mixer', 'Kettle']

Keeping ties

idxmax returns only the first maximum. To keep every product that shares the top total, compare each row with its city's maximum:

Python
top = totals[totals["revenue"] == totals.groupby("city")["revenue"].transform("max")]
print(len(top))   # 3: no ties in this data

Follow-ups

  • What does transform do? It returns one value per original row (here, the city's maximum), aligned to the rows, unlike agg, which returns one row per group.
  • How would you do this in SQL? With GROUP BY and a window function such as RANK(); see the SQL & DBMS kit, questions 21 and 29.
Q27Not marked yetFresherAsked oftenCoding

Count missing values in each column of a DataFrame, fill numeric gaps with the column median and drop rows where the target is missing

Say this first~30 sec to say

df.isna().sum() counts the missing values per column. Drop rows with no target using dropna(subset=["target"]): you can't learn from a row without an answer, and you never impute the target. Then fill the numeric gaps: select the numeric columns, compute their medians, and assign df[cols] = df[cols].fillna(medians) back. Check dtypes first, since a number stored as text won't count as numeric. In a real ML workflow, compute the medians on the training split only.

Could you say it?
Full answer2 min read
Python
import numpy as np
import pandas as pd

df = pd.DataFrame({
    "age": [25, np.nan, 31, 45, np.nan],
    "income": [52_000, np.nan, 61_000, 250_000, 58_000],
    "city": ["Pune", None, "Delhi", "Pune", "Chennai"],
    "churned": [0, 1, np.nan, 0, 1],
})

print(df.isna().sum())                        # age 2, income 1, city 1, churned 1

df = df.dropna(subset=["churned"])            # no target, no training example
numeric = df.select_dtypes("number").columns.drop("churned")
df[numeric] = df[numeric].fillna(df[numeric].median())
df["churned"] = df["churned"].astype(int)
print(df)
    age    income     city  churned
0  25.0   52000.0     Pune        0
1  35.0   58000.0      NaN        1
3  45.0  250000.0     Pune        0
4  35.0   58000.0  Chennai        1

The median ignores the ₹2.5 lakh outlier; the mean income of those rows would have been 120,000. The missing city is a text column, so it's left for a separate decision: fill it with the most frequent city or an explicit "Unknown".

A pandas 3 trap: inplace on a column

Python
d = pd.DataFrame({"age": [1.0, np.nan]})
d["age"].fillna(0, inplace=True)      # works on a copy; prints a ChainedAssignmentError warning
print(d["age"].tolist())              # [1.0, nan]: unchanged
d["age"] = d["age"].fillna(0)         # assign the result back instead
print(d["age"].tolist())              # [1.0, 0.0]

Under pandas 3's copy-on-write rules, d["age"] is a new object, so changing it in place never reaches d. Older tutorials show the inplace version working.

Follow-ups

  • What share of each column is missing? df.isna().mean() gives the fraction per column.
  • Why not compute the median on all the data? Because it leaks information from the validation and test rows into training; use SimpleImputer inside a pipeline (question 9).
Q28Not marked yetFresherAsked oftenCoding

Combine a customers DataFrame with an orders DataFrame so every customer appears, then count orders per customer, showing 0 for those with none

Say this first~30 sec to say

customers.merge(orders, on="customer_id", how="left") keeps every customer, with NaN in the order columns for those who never ordered. Then group by customer and count a column from the orders side, such as order_id: count() skips NaN, so those customers get 0, whereas size() would count their single NaN row as 1. An inner merge would drop them entirely, and an outer merge would also keep orders whose customer doesn't exist.

Could you say it?
Full answer2 min read
Python
import pandas as pd

customers = pd.DataFrame({"customer_id": [1, 2, 3, 4], "name": ["Asha", "Ravi", "Priya", "Arjun"]})
orders = pd.DataFrame({"order_id": [101, 102, 103, 104], "customer_id": [1, 1, 3, 9]})

merged = customers.merge(orders, on="customer_id", how="left")
counts = (merged.groupby(["customer_id", "name"], as_index=False)["order_id"].count()
                .rename(columns={"order_id": "orders"}))
print(counts)
print(merged.groupby("customer_id").size().tolist())   # [2, 1, 1, 1]: size() wrongly gives Ravi and Arjun 1
   customer_id   name  orders
0            1   Asha       2
1            2   Ravi       0
2            3  Priya       1
3            4  Arjun       0

What each how keeps

Python
print(customers.merge(orders, on="customer_id", how="outer", indicator=True))
   customer_id   name  order_id      _merge
0            1   Asha     101.0        both
1            1   Asha     102.0        both
2            2   Ravi       NaN   left_only
3            3  Priya     103.0        both
4            4  Arjun       NaN   left_only
5            9    NaN     104.0  right_only
howKeeps
innerOnly customers with orders, and orders with a known customer
leftEvery customer
rightEvery order, including order 104 for the unknown customer 9
outerEverything from both sides

indicator=True adds the _merge column, which is the quickest way to debug a join that returns the wrong number of rows.

Without a merge at all

Python
counts = orders["customer_id"].value_counts().reindex(customers["customer_id"], fill_value=0)
print(counts.tolist())   # [2, 0, 1, 0]

Follow-ups

  • merge vs join vs concat? merge joins on columns, like SQL. join is a shortcut that joins on the index. concat stacks DataFrames vertically or side by side without matching keys.
  • How do you catch an accidental many-to-many join? Pass validate="one_to_many"; pandas raises an error if the keys aren't unique on the left.
  • What's the SQL version? A LEFT JOIN with COUNT(o.order_id); see the SQL & DBMS kit, question 22.
Q29Not marked yet1–3 yrsAsked sometimesCoding

A row-by-row apply that computes a final price (price minus discount, members only) is slow on 5 million rows. Rewrite it with vectorised pandas or NumPy and explain the speed-up

Say this first~30 sec to say

Replace the apply, which calls a Python function once per row, with whole-column operations: subtract discount from price as one column operation, and use np.where(df["is_member"], discounted, df["price"]) to choose per row. Those run as a few loops in compiled C over contiguous arrays instead of millions of Python calls, typically hundreds of times faster. NumPy beats Python lists because an array stores one numeric type packed together, without a full Python object per element.

Could you say it?
Full answer3 min read
Python
import time
import numpy as np
import pandas as pd

rng = np.random.default_rng(0)
n = 500_000                               # the same idea holds at 5 million, only slower
df = pd.DataFrame({
    "price": rng.integers(100, 5_000, n),
    "discount": rng.integers(0, 50, n),
    "is_member": rng.random(n) < 0.3,
})

def final_price(row):
    return row["price"] - row["discount"] if row["is_member"] else row["price"]

start = time.perf_counter()
slow = df.apply(final_price, axis=1)                  # one Python call per row
apply_time = time.perf_counter() - start

start = time.perf_counter()
fast = np.where(df["is_member"], df["price"] - df["discount"], df["price"])   # whole columns at once
vector_time = time.perf_counter() - start

print((slow.to_numpy() == fast).all())                # True: the same answer
print(f"apply {apply_time:.2f}s, vectorised {vector_time:.4f}s")   # roughly 1 s against under a millisecond

Another vectorised way, with loc

Python
df["final"] = df["price"]
df.loc[df["is_member"], "final"] -= df["discount"]
print((df["final"].to_numpy() == fast).all())         # True

Why NumPy is faster than a Python list

Python
import sys

nums = list(range(1_000_000))
arr = np.arange(1_000_000)
print(sys.getsizeof(nums) + sum(sys.getsizeof(x) for x in nums))   # 36000056: about 36 MB
print(arr.nbytes)                                                   # 8000000: 8 MB
  • A list holds pointers to separate Python int objects, each with its own type information and reference count. An array holds raw 8-byte numbers side by side.
  • An operation on an array runs one compiled loop with no type checks per element, and modern CPUs can process several packed numbers per instruction.
  • df.apply(axis=1) builds a Series for every row and calls your Python function on it, which is why it's the slowest option.

Follow-ups

  • What if the logic has several conditions? Use np.select(conditions, choices, default=...), which is the vectorised if / elif / else.
  • When is a loop acceptable? When the logic truly depends on the previous row's result, and even then, Numba or Cython can compile it.

07Deep learning basics

0/4 got it

Q30Not marked yetFresherAsked oftenConcept

Why does a neural network need activation functions, and where would you use ReLU, sigmoid and softmax?

Say this first~25 sec to say

Without a non-linear activation, every layer is linear, and stacked linear layers collapse into one, so depth adds nothing. Activations let networks learn curved boundaries and complex patterns. Use ReLU, max(0, x), in hidden layers: it's cheap and keeps gradients alive. Use sigmoid at the output for binary classification, and softmax for multi-class, since it turns scores into probabilities that sum to 1.

Could you say it?
Full answer2 min read
Python
import numpy as np

def relu(x):
    return np.maximum(0, x)

def sigmoid(x):
    return 1 / (1 + np.exp(-x))

def softmax(scores):
    exp = np.exp(scores - scores.max())     # subtracting the max avoids overflow
    return exp / exp.sum()

z = np.array([-2.0, 0.0, 3.0])
print(relu(z))                  # [0. 0. 3.]
print(sigmoid(z).round(3))      # [0.119 0.5   0.953]
print(softmax(z).round(3), softmax(z).sum())   # [0.006 0.047 0.946] 1.0

Two linear layers collapse into one

Python
rng = np.random.default_rng(0)
W1, W2 = rng.normal(size=(4, 3)), rng.normal(size=(2, 4))
x = rng.normal(size=3)
print(np.allclose(W2 @ (W1 @ x), (W2 @ W1) @ x))   # True: one matrix does the work of both
ActivationOutput rangeTypical useWatch out for
ReLU0 to ∞Hidden layers: the default"Dead" units stuck at 0
Leaky ReLU, GELURoughly −small to ∞Hidden layers; GELU is standard in transformersSlightly more computation
Sigmoid0 to 1Binary output; multi-label outputsVanishing gradients in hidden layers
Tanh−1 to 1Older RNNs; centred hidden unitsVanishing gradients
Softmax0 to 1, summing to 1Multi-class outputOnly for mutually exclusive classes
None (linear)Any valueRegression output

Follow-ups

  • What is a dead ReLU? A unit whose input is always negative outputs 0 and gets zero gradient, so it never recovers. Leaky ReLU keeps a small slope below zero.
  • Sigmoid or softmax for multi-label tasks, such as tagging a photo "beach" and "sunset"? Sigmoid per label, since the labels aren't mutually exclusive.
Q31Not marked yetFresherAsked oftenConcept

Explain backpropagation in plain words, without the maths

Say this first~25 sec to say

Training repeats three steps. The forward pass runs the inputs through the network and measures the loss. Backpropagation works out how much each weight contributed to that loss, starting at the output and moving backwards layer by layer, reusing each layer's result for the one before: that's the chain rule. Then the optimiser nudges every weight slightly in the direction that lowers the loss.

Could you say it?
Full answer2 min read

An analogy: a relay team loses a race. The coach starts with the final runner's split time, works backwards to see how much each earlier runner's pace contributed, and gives each one a correction proportional to their share of the blame. Backpropagation does that for every weight, in one backward sweep.

A whole network, trained by hand in NumPy

XOR can't be learned by a linear model, so the network needs a hidden layer, and backpropagation to train it:

Python
import numpy as np

def sigmoid(z):
    return 1 / (1 + np.exp(-z))

rng = np.random.default_rng(1)
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float)
y = np.array([[0], [1], [1], [0]], dtype=float)          # XOR

W1, b1 = rng.normal(size=(2, 8)), np.zeros(8)
W2, b2 = rng.normal(size=(8, 1)), np.zeros(1)

for step in range(5000):
    # 1. Forward pass
    h = np.tanh(X @ W1 + b1)
    out = sigmoid(h @ W2 + b2)
    # 2. Backward pass: the chain rule, from the output back to the first layer
    d_out = out - y                        # the loss gradient at the output (log loss + sigmoid)
    d_W2, d_b2 = h.T @ d_out, d_out.sum(axis=0)
    d_h = (d_out @ W2.T) * (1 - h ** 2)    # back through tanh
    d_W1, d_b1 = X.T @ d_h, d_h.sum(axis=0)
    # 3. Update every weight a little, downhill
    for param, grad in ((W1, d_W1), (b1, d_b1), (W2, d_W2), (b2, d_b2)):
        param -= 0.1 * grad

print(out.round(2).ravel())   # [0. 1. 1. 0.]: XOR learned

Frameworks such as PyTorch and TensorFlow do the backward pass for you: loss.backward() computes every gradient automatically (autograd), and optimizer.step() applies the update.

Follow-ups

  • Why is it efficient? One backward pass computes the gradients for all the weights, at about the cost of a forward pass, instead of nudging each weight separately to see what happens.
  • What can go wrong? Gradients can shrink to nothing or blow up as they travel back through many layers (question 33).
Q32Not marked yetFresherAsked oftenConcept

What kind of data are CNNs and RNNs built for, and what in their design suits them to it?

Say this first~25 sec to say

CNNs suit grid data like images: small shared filters slide over local patches, so one edge detector works anywhere with few weights, pooling tolerates small shifts, and deeper layers build edges into objects. RNNs suit sequences like text, speech and time series: they read one step at a time, carrying a hidden state forward as memory, with the same weights at every step.

Could you say it?
Full answer3 min read

A filter is a small pattern detector, reused everywhere

Python
import numpy as np

image = np.array([
    [0, 0, 0, 9, 9, 9],
    [0, 0, 0, 9, 9, 9],
    [0, 0, 0, 9, 9, 9],
    [0, 0, 0, 9, 9, 9],
], dtype=float)                                   # dark on the left, bright on the right
vertical_edge = np.array([[-1, 0, 1]] * 3, dtype=float)

def convolve(img, kernel):
    kh, kw = kernel.shape
    out = np.zeros((img.shape[0] - kh + 1, img.shape[1] - kw + 1))
    for i in range(out.shape[0]):
        for j in range(out.shape[1]):
            out[i, j] = np.sum(img[i:i + kh, j:j + kw] * kernel)   # the same 9 weights at every position
    return out

print(convolve(image, vertical_edge))
# [[ 0. 27. 27.  0.]
#  [ 0. 27. 27.  0.]]: it fires only where the edge is

In a CNN the filter values aren't hand-written like this; they're learned. A layer of 32 such 3 × 3 filters on a colour image has 3 × 3 × 3 × 32 + 32 = 896 weights. A fully connected layer from a 224 × 224 colour image to just 32 units would need over 4.8 million.

An RNN carries memory from step to step

Python
rng = np.random.default_rng(0)
W_x, W_h, b = rng.normal(size=(4, 3)), rng.normal(size=(4, 4)) * 0.5, np.zeros(4)

h = np.zeros(4)                                   # the memory starts empty
for x_t in rng.normal(size=(5, 3)):               # a sequence of 5 steps, 3 features each
    h = np.tanh(W_x @ x_t + W_h @ h + b)          # the same weights at every step
print(h.shape)                                    # (4,): a summary of the whole sequence
CNNRNN
Built forImages, and grid-like data such as spectrogramsSequences: text, audio, time series
Key ideaShared local filters, poolingA hidden state passed from step to step
What it exploitsNearby pixels are related; patterns can appear anywhereOrder matters; earlier steps inform later ones
WeaknessFixed-size local views (deep stacks widen them)Slow, step-by-step; forgets over long distances
TodayStill common in vision, alongside vision transformersLargely replaced by transformers for text (question 35)

Follow-ups

  • Why an LSTM over a plain RNN? Its gates decide what to keep, forget and output, so gradients survive across long sequences; the plain RNN's memory fades quickly (question 33).
  • Can a CNN handle text? Yes, with 1-D convolutions over word embeddings, which work well for short-text classification.
Q33Not marked yet1–3 yrsAsked oftenConcept

What is the vanishing gradient problem, and how do modern networks get around it?

Say this first~25 sec to say

Backpropagation multiplies the gradient by each layer's local derivative. With sigmoid, whose slope is at most 0.25, those factors are small, so the gradient shrinks exponentially through many layers or time steps and the early layers barely learn. The fixes: ReLU-family activations, He or Xavier initialisation, normalisation layers, residual connections that give gradients a shortcut, and LSTM or GRU gates or transformers for sequences.

Could you say it?
Full answer3 min read

The arithmetic of shrinking

Python
for layers in (5, 10, 20):
    print(layers, 0.25 ** layers)   # the best case for sigmoid: its slope is at most 0.25
5 0.0009765625
10 9.5367431640625e-07
20 9.094947017729282e-13

Measured in a real 30-layer network

Python
import numpy as np

rng = np.random.default_rng(0)

def first_layer_gradient(activation, depth=30, width=64):
    x = rng.normal(size=width)
    weights, inputs = [], []
    for _ in range(depth):                        # forward pass through the layers
        scale = np.sqrt(2 / width) if activation == "relu" else np.sqrt(1 / width)
        W = rng.normal(0, scale, (width, width))
        z = W @ x
        weights.append(W)
        inputs.append(z)
        x = np.maximum(0, z) if activation == "relu" else 1 / (1 + np.exp(-z))
    grad = np.ones(width)                         # pretend the loss gradient at the top is 1
    for W, z in zip(reversed(weights), reversed(inputs)):   # backward pass
        if activation == "relu":
            local = (z > 0).astype(float)
        else:
            s = 1 / (1 + np.exp(-z))
            local = s * (1 - s)
        grad = W.T @ (grad * local)
    return np.linalg.norm(grad)

print(f"sigmoid: {first_layer_gradient('sigmoid'):.2e}")   # sigmoid: 1.57e-18
print(f"relu:    {first_layer_gradient('relu'):.2e}")      # relu:    1.94e+01

With sigmoid, the first layer receives a gradient around 10⁻¹⁸: it effectively never learns. With ReLU and He initialisation, the signal survives all 30 layers.

FixWhy it helps
ReLU, GELUA slope of 1 for positive inputs, so the gradient isn't squeezed at every layer
He or Xavier initialisationStarting weights sized to keep signals at a steady scale through the layers
Batch or layer normalisationKeeps each layer's inputs in a well-behaved range
Residual connections (ResNet, transformers)output = layer(x) + x adds a path the gradient can flow straight down
LSTM and GRU gatesAn additive memory path that can carry information across many time steps

The opposite problem, exploding gradients, happens when the factors are above 1: the gradient grows exponentially and training diverges. Gradient clipping, which caps the gradient's size, is the usual fix, especially for RNNs.

Follow-ups

  • How do you spot vanishing gradients? Early layers' weights barely change and their gradient norms are tiny compared with the later layers'.
  • Why do transformers suffer less? Residual connections and layer normalisation around every block, and attention links any two positions directly.

08NLP and transformers

0/2 got it

Q34Not marked yetFresherAsked oftenConcept

What are tokens and embeddings, and why do embeddings capture meaning better than one-hot or TF-IDF vectors?

Say this first~25 sec to say

A tokeniser splits text into tokens, usually sub-word pieces mapped to integer IDs, so rare and new words are built from known pieces. An embedding layer maps each ID to a learned dense vector of a few hundred numbers, where words used in similar contexts sit close together. One-hot and TF-IDF vectors are huge and sparse, and treat every word as unrelated: "car" and "automobile" share nothing.

Could you say it?
Full answer3 min read

TF-IDF can't tell that two words mean the same thing

Python
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

docs = ["I bought a new car", "I bought a new automobile", "I bought a new phone"]
tfidf = TfidfVectorizer().fit_transform(docs)
print(cosine_similarity(tfidf).round(2))
[[1.   0.41 0.41]
 [0.41 1.   0.41]
 [0.41 0.41 1.  ]]

The car sentence is exactly as similar to the phone sentence as to the automobile one: each word is just a separate column.

Embeddings place similar meanings close together

Python
# Hand-made 3-number vectors, for illustration only: real embeddings
# have hundreds of dimensions and are learned from huge amounts of text.
emb = {
    "car":        np.array([0.9, 0.1, 0.0]),
    "automobile": np.array([0.8, 0.2, 0.1]),
    "phone":      np.array([0.1, 0.9, 0.2]),
}

def cosine(a, b):
    return a @ b / (np.linalg.norm(a) * np.linalg.norm(b))

print(round(cosine(emb["car"], emb["automobile"]), 2))   # 0.98: close in meaning
print(round(cosine(emb["car"], emb["phone"]), 2))        # 0.21: not related

Tokenisation in practice: a sub-word tokeniser (BPE, WordPiece or SentencePiece) might keep "the" as one token but split a rarer word like "tokenisation" into pieces such as "token" and "isation". The exact splits depend on the tokeniser. LLM limits and prices are counted in tokens, and in English a token averages about three-quarters of a word.

One-hotTF-IDFEmbeddings
SizeOne column per vocabulary wordOne column per vocabulary wordA few hundred numbers
Sparse or denseSparseSparseDense
Captures similarityNoOnly through shared wordsYes
LearnedNoNo (counts)Yes

Static vs contextual: Word2Vec and GloVe give each word one fixed vector, so "bank" is the same in "river bank" and "bank loan". Transformer models produce contextual embeddings, a different vector for each use, depending on the surrounding words.

Follow-ups

  • What does "king − man + woman ≈ queen" show? That word2vec embeddings encode relationships as directions in the vector space.
  • Why sub-words rather than whole words? A whole-word vocabulary can't cover every name, typo or new word. Sub-words handle any input with a vocabulary of tens of thousands of tokens.
Q35Not marked yet1–3 yrsAsked oftenConcept

What does self-attention do, and why did transformers largely replace RNNs for language tasks?

Say this first~30 sec to say

Self-attention lets every token weigh every other token in the sequence. Each token becomes a query, a key and a value; its query is scored against every key, softmax turns the scores into weights, and its new vector is the weighted sum of the values. Transformers replaced RNNs because attention links any two positions in one step, and the whole sequence is processed in parallel: faster training, and long-range links are easier to learn.

Could you say it?
Full answer2 min read
Python
import numpy as np

def softmax(x, axis=-1):
    e = np.exp(x - x.max(axis=axis, keepdims=True))
    return e / e.sum(axis=axis, keepdims=True)

def self_attention(X, Wq, Wk, Wv):
    Q, K, V = X @ Wq, X @ Wk, X @ Wv
    scores = Q @ K.T / np.sqrt(K.shape[1])   # how well each token's query matches every key
    weights = softmax(scores)                # each row sums to 1
    return weights @ V, weights              # a weighted mix of the values

rng = np.random.default_rng(0)
X = rng.normal(size=(3, 4))                  # 3 tokens ("the", "cat", "sat"), 4-number embeddings
Wq, Wk, Wv = (rng.normal(size=(4, 4)) for _ in range(3))

out, weights = self_attention(X, Wq, Wk, Wv)
print(weights.sum(axis=1))                   # [1. 1. 1.]: each token's attention adds up to 1
print(out.shape)                             # (3, 4): a new, context-aware vector per token

The weights would be learned in a real model. After training, the row for "it" in "The cat sat because it was tired" puts much of its weight on "cat".

The other pieces of a transformer

  • Multi-head attention: several attention operations side by side, each free to track a different kind of relationship.
  • Positional information: attention on its own ignores word order, so positions are added to the embeddings.
  • Masking: in a decoder such as GPT, each token may only attend to earlier tokens, so the model can't peek at the word it's predicting.
  • Feed-forward layers, residual connections and layer normalisation around each attention block.
RNNTransformer
Processes a sequenceOne step at a timeAll positions at once
Distance between two tokensUp to n steps of passed-on memoryOne attention step
Training on GPUsHard to paralleliseHighly parallel
Cost in sequence length nO(n)O(n²) attention scores

Follow-ups

  • Why divide by √d? Without it, dot products grow with the vector size, pushing softmax into regions with tiny gradients.
  • What's the downside of attention? Its cost grows with the square of the sequence length, which is why long contexts are expensive and why efficient-attention variants exist.

09GenAI and LLMs

0/5 got it

Q36Not marked yetFresherAsked oftenConcept

What is retrieval-augmented generation (RAG), and what happens between a user's question and the model's answer?

Say this first~30 sec to say

RAG lets an LLM answer from your own documents without retraining it. Beforehand, documents are split into chunks, each chunk is turned into an embedding, and the vectors are stored in a vector database. At question time, the question is embedded too, the most similar chunks are retrieved, and they're added to the prompt with an instruction to answer from them. The model then answers from private or recent information it never saw in training, and can cite its sources.

Could you say it?
Full answer3 min read
Indexing (once, and again when documents change):
  documents → chunks → embeddings → vector database

Answering (every question):
  question → embedding → top-k most similar chunks → prompt = instructions + chunks + question → LLM → answer with citations

The retrieval step, end to end

TF-IDF stands in for a real embedding model here, so the example runs anywhere; the shape of the code is the same:

Python
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

chunks = [
    "Employees get 24 days of paid leave per year, credited monthly.",
    "Laptops are refreshed every three years; raise a ticket with IT.",
    "Unused paid leave of up to 10 days can be carried into the next year.",
    "The office is closed on national holidays listed on the HR portal.",
]
vectorizer = TfidfVectorizer().fit(chunks)      # stands in for the embedding model
index = vectorizer.transform(chunks)            # stands in for the vector database

question = "How many leave days can I carry forward?"
scores = cosine_similarity(vectorizer.transform([question]), index).ravel()
top = scores.argsort()[::-1][:2]                # the two most similar chunks

context = "\n".join(f"[{i}] {chunks[i]}" for i in top)
prompt = (
    "Answer using only the context below and cite the chunk numbers. "
    "If the answer isn't there, say you don't know.\n\n"
    f"Context:\n{context}\n\nQuestion: {question}"
)
print(prompt)
Answer using only the context below and cite the chunk numbers. If the answer isn't there, say you don't know.

Context:
[2] Unused paid leave of up to 10 days can be carried into the next year.
[0] Employees get 24 days of paid leave per year, credited monthly.

Question: How many leave days can I carry forward?

That prompt then goes to the LLM.

What is a vector database? A store for embeddings plus their text and metadata, with fast approximate nearest-neighbour search, so the closest chunks among millions are found in milliseconds. Examples: FAISS (a library), pgvector (a PostgreSQL extension), Pinecone, Weaviate, Qdrant, Milvus and Chroma.

Design choices that decide quality

  • Chunking: too small loses context; too large dilutes relevance. A few hundred tokens with some overlap is a common start.
  • Retrieval: combine vector search with keyword search (hybrid), since exact terms such as product codes matter, then rerank the candidates.
  • Metadata filters: restrict results to the right product, version or language, and to documents the user is allowed to see.

Follow-ups

  • What goes wrong in RAG? Usually retrieval: the right chunk isn't found, so the model guesses (question 38).
  • RAG or fine-tuning? RAG for knowledge, fine-tuning for behaviour (question 39).
Q37Not marked yetFresherAsked oftenConcept

Explain zero-shot, few-shot and chain-of-thought prompting. When would you use each?

Say this first~25 sec to say

Zero-shot gives only the instruction: fine for common, clear tasks like a two-line summary. Few-shot adds a few worked examples, which pin down labels, format and edge cases. Chain-of-thought asks for step-by-step reasoning before the answer, which helps with maths and logic; newer reasoning models need it less. Whatever the technique, clear constraints and an exact output format matter just as much.

Could you say it?
Full answer2 min read

Zero-shot

Classify the sentiment of this review as positive, negative or neutral.
Review: "Delivery was quick but the kettle stopped working after a week."
Sentiment:

Few-shot: show the exact labels and format you want

Classify each support ticket as BILLING, DELIVERY or PRODUCT. Reply with the label only.

Ticket: "I was charged twice for one order." → BILLING
Ticket: "My parcel says delivered but I never got it." → DELIVERY
Ticket: "The mixer's lid doesn't fit." → PRODUCT

Ticket: "Refund still not credited after 10 days." →

Chain-of-thought: ask for the working first

A shop sells pens at ₹12 each, or ₹100 for a pack of 10. Ravi needs 23 pens.
What is the cheapest total? Work through the options step by step, then give
the final answer on its own line as "Answer: ₹<amount>".

(Two packs and three single pens: ₹200 + ₹36 = ₹236.)

TechniqueUse whenCost
Zero-shotThe task is common and clearly specifiedShortest prompt
Few-shotLabels, format or tone need pinning down; edge cases are trickyLonger prompt; examples can bias the output
Chain-of-thoughtMulti-step reasoning: maths, logic, planningLonger, slower and pricier output

What makes any prompt better

  • Say who the audience is and what the task is for.
  • State constraints explicitly: length, tone, what not to do.
  • Fix the output format, ideally a JSON schema the code can check.
  • Say what to do when unsure: "If the information isn't provided, say so."
  • Keep instructions and data clearly separated, for example with labelled sections.

Follow-ups

  • How do you pick few-shot examples? Cover each label and the tricky edge cases, keep them consistent, and vary their order so the model doesn't learn a pattern from position.
  • Should you show the chain of thought to users? Usually not: show the final answer, and keep the reasoning for logging or for a separate checking step.
Q38Not marked yet1–3 yrsAsked sometimesScenario

Your chatbot over product manuals sometimes describes features the product doesn't have. Why might this happen, and how would you fix it?

Say this first~25 sec to say

That's hallucination: fluent text not grounded in the sources, often because retrieval missed the right chunk and the model filled the gap. Check retrieval first by logging what came back for the failing questions. Then tighten the prompt: answer only from the context, say "I don't know" otherwise, and cite chunk IDs. Improve retrieval, lower the temperature, and test with known and unanswerable questions.

Could you say it?
Full answer2 min read

Debug it in order

CheckWhat you might findFix
What did retrieval return?The wrong model's manual, or nothing relevantMetadata filters (product, version), hybrid search, reranking
Was the right chunk retrieved, but cut off?The key sentence split across chunksLarger chunks with overlap; chunk by section headings
Does the prompt allow guessing?No rule for missing information"Answer only from the context; otherwise say you don't know"
Can answers be checked?No citationsRequire chunk IDs; reject or flag answers without them
Is sampling too creative?High temperatureLower the temperature for factual answers
Is the question unanswerable?The feature really doesn't existInclude such questions in tests; expect a refusal

A cheap automatic guard: check the citations

Python
import re

def check_citations(answer, retrieved_ids):
    cited = {int(n) for n in re.findall(r"\[(\d+)\]", answer)}
    return {
        "cited": sorted(cited),
        "no_citation": not cited,
        "not_retrieved": sorted(cited - set(retrieved_ids)),   # cites something it wasn't given
    }

print(check_citations("Unused leave carries over, up to 10 days [2].", [2, 0]))
# {'cited': [2], 'no_citation': False, 'not_retrieved': []}
print(check_citations("Yes, the X200 supports wireless charging.", [5, 7]))
# {'cited': [], 'no_citation': True, 'not_retrieved': []}

An uncited claim isn't proof of hallucination, but it's the right one to send for review or answer with a fallback.

Follow-ups

  • Can hallucination be eliminated? Not entirely. You reduce it with grounding, constraints and checks, and design the product so a wrong answer is caught or cheap.
  • How would you measure the improvement? A fixed set of real questions with known answers, scored before and after the change (question 40).
Q39Not marked yet1–3 yrsAsked sometimesScenario

A company wants an assistant that answers from thousands of internal policy documents updated every month. Would you fine-tune a model, use RAG, or combine them?

Say this first~30 sec to say

RAG. The knowledge is large, private and changes monthly, and answers should point to the policy they came from. With RAG, an update means re-indexing the changed documents, the same day. Fine-tuning would mean retraining every month, wouldn't reliably memorise exact facts, and can't cite sources. Fine-tuning is the tool for behaviour, not knowledge: a consistent tone, a strict output format, or a narrow task. Combine them only if the assistant also needs that.

Could you say it?
Full answer2 min read
RAGFine-tuningBoth
ChangesWhat the model can look upHow the model behavesBoth
Keeping currentRe-index the changed filesRetrain on new examplesRe-index; retrain rarely
CitationsYes: the retrieved chunksNoYes
Access controlFilter retrieval by the user's permissionsImpossible: whatever was trained in is available to everyoneThrough retrieval
Data neededThe documentsHundreds to thousands of good input–output examplesBoth
Good forFacts, policies, manuals, anything that changesTone, format, classification, domain styleA house style over changing facts

The access-control point is often decisive: HR policies visible to managers only must never reach other employees. RAG can filter by the user's permissions before retrieval; a fine-tuned model can't "un-know" what it was trained on.

Why not paste every document into a long context window? Thousands of documents don't fit, every question would pay for all those tokens, and answers get slower and often less accurate when the relevant passage is buried. Retrieval sends only what matters.

Follow-ups

  • When would you fine-tune here after all? If answers must follow a strict format, such as citing the policy number and effective date every time, and prompting alone isn't reliable enough.
  • How would you keep the index fresh? Re-embed documents when they change, and store each chunk's version and effective date, so answers can say which version they used.
Q40Not marked yet1–3 yrsAsked sometimesScenario

You rewrote the prompt behind a support chatbot. How would you show the new version is actually better, not just better on a few examples?

Say this first~25 sec to say

Build a fixed evaluation set first: a few hundred real questions, including hard and unanswerable ones, each with the facts a good answer needs. Run both prompts on the same set and score correctness, faithfulness to the sources and format, with human review on a sample plus an LLM judge checked against the humans. Compare side by side, study the regressions, and keep the set for future changes.

Could you say it?
Full answer3 min read

A tiny version of the idea

Real evaluations use rubrics and human review; this keyword check just shows the shape, scoring two prompt versions on the same questions:

Python
eval_set = [
    {"question": "How many paid leave days do I get?", "must_include": ["24"]},
    {"question": "Can I carry leave forward?", "must_include": ["10 days"]},
    {"question": "What is the CEO's salary?", "must_include": ["don't know"]},   # not in the documents
]
answers = {
    "old prompt": ["You get 24 days.", "Yes, without any limit.", "The CEO earns ₹5 crore."],
    "new prompt": ["You get 24 days [0].", "Yes, up to 10 days [2].", "I don't know; that isn't in the policies."],
}

for version, outputs in answers.items():
    passed = [all(fact.lower() in out.lower() for fact in case["must_include"])
              for case, out in zip(eval_set, outputs)]
    failed = [i for i, ok in enumerate(passed) if not ok]
    print(f"{version}: {sum(passed)}/{len(passed)} passed, failed: {failed}")
old prompt: 1/3 passed, failed: [1, 2]
new prompt: 3/3 passed, failed: []

What to measure

MeasureQuestion it answers
CorrectnessDoes it match the reference facts?
Faithfulness (groundedness)Is every claim supported by the retrieved sources?
RefusalsDoes it say "I don't know" when the answer isn't available, and only then?
Format and toneDoes it follow the required structure and voice?
Latency and costIs it still fast and affordable?

Avoiding fooling yourself

  • Freeze the set before you start tuning, and add a separate held-out set, so you don't tune the prompt to the test.
  • Break results down by category: a better average can hide a new failure on, say, billing questions.
  • Small differences can be noise: with 50 questions, one or two flips mean little. Use more cases, or repeated runs, before declaring a winner.
  • Then confirm in production: an A/B test on live traffic, watching thumbs-up rates, escalations to human agents and repeat contacts.

Follow-ups

  • How do you trust an LLM judge? Give it a precise rubric, have it explain each score, and compare its scores with human ratings on a sample. Swap the order of the two answers to catch position bias.
  • What makes a good eval set? Real user questions, not invented ones, covering every intent, with the hard and unanswerable cases deliberately over-represented.

Last step

Write these from memory

Before an ML or data science interview, make sure you can write each of these on a blank page without help:

  • Q3
  • Q9
  • Q15
  • Q18
  • Q22
  • Q24
  • Q25
  • Q26
  • Q28
  • Q29
  • Q30
  • Q35
  • Q36

0 of 13 done