Back to the map
DataCore9h

Imbalanced and messy data

The case where accuracy lies loudest.

Many problems worth solving have a rare answer: one transaction in a thousand is fraud, one part in five hundred is defective. If the rare case is one per cent of the data, a model that always says no is right ninety-nine per cent of the time and completely useless. This topic is that situation and the handling of it — choosing a metric that cannot be gamed this way, weighting the rare class more heavily, resampling, and moving the threshold at which the model commits to an answer.

Accuracy conceals total failure here, and accuracy is the number everyone reports first. What ships is either a model that never fires, which nobody notices for a quarter, or one that fires so often the alerts get switched off. Both look fine in the notebook.

It rests on model evaluation, which is where precision, recall and thresholds come from, and it recurs in everything with rare events: fraud, defect detection, medical screening, anomaly detection. It pairs with data quality, since real imbalanced datasets are also the messy ones.

With a rare class, accuracy is a number you can score highly on while doing nothing at all.

In more detailWhen one class is 1% of the data, a model that always predicts the majority scores 99%. Resampling, class weighting and threshold tuning help, but the first move is choosing a metric that cannot be gamed this way.

Section 1 of 2, 5h

When one class is rare

By the end: You can build a useful model when the interesting class is 1 percent of the data.

  • Why accuracy fails here
  • Resampling and class weights
  • Threshold tuning
  • What to actually optimise

When the thing you care about is one percent of the data, a model that always says no is ninety-nine percent accurate and completely useless. This section is about the case where the interesting class is rare: why the usual metric lies loudest here, what resampling and class weights actually do, and why the most useful lever is often the threshold you apply afterwards rather than anything in training.

Base rate      Accuracy of "always no"   What actually changes
10%            90%                       almost nothing; use the normal tools
1%             99%                       stop reporting accuracy; move the threshold
0.1%           99.9%                     threshold and cost become the whole problem
0.01%          99.99%                    ranking, not classification: score and review the top k
1 in a million 99.9999%                  this is anomaly detection, not classification
Worth memorising. The techniques do not change gradually down this table; at some point the question stops being which class and becomes which to look at first.

The setup#

Fraud, defects, disease, churn. The interesting class is rare, which means a model predicting the majority every time scores extremely well and is useless.

1000 transactions, 10 fraudulent

Always predict "not fraud":
  accuracy 99%
  fraud caught 0

The arithmetic is worth doing once at a realistic ratio rather than a convenient one. Everything below uses a generated dataset of 200,000 rows with a positive rate of about 0.14 per cent, which is 700 to 1. That is an ordinary fraud or defect rate, and it is far enough into the table above that the usual advice starts to break down.

from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix, accuracy_score

X, y = make_classification(n_samples=200_000, n_features=20, n_informative=6,
                           weights=[0.999, 0.001], flip_y=0.001, random_state=0)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.3, stratify=y, random_state=0)
print(ytr.sum(), len(ytr))       # 201 positives in 140,000 rows = 0.144%

print(1 - yte.mean())            # 0.99857   accuracy of a model that says no to everything

plain = LogisticRegression(max_iter=2000).fit(Xtr, ytr)
s = plain.predict_proba(Xte)[:, 1]
tn, fp, fn, tp = confusion_matrix(yte, s >= 0.5).ravel()
print(accuracy_score(yte, s >= 0.5), tp, fp, fn)
# 0.99858    tp 1   fp 0   fn 85
Runnable in about twenty seconds. The model beat the do-nothing baseline by 0.00001 of accuracy, by catching one of the 86 positives.

That is the failure in full. The model is not broken and it is not untrained; it has correctly learned that predicting the majority is almost always right, because that is exactly what the loss function asked for. Accuracy went up. One case in 86 was caught. Reported as 99.86 per cent accurate, this would be signed off.

The options#

  • Class weights: tell the model a mistake on the rare class costs more. Simplest, usually try first.
  • Oversampling the minority, including synthetic methods like SMOTE. Risks overfitting to the few real examples.
  • Undersampling the majority. Fast, and throws away data you paid for.
  • Threshold tuning: leave the model alone and move the decision boundary. Frequently the largest win for the least effort.
model = LogisticRegression(class_weight="balanced").fit(Xtr, ytr)

probs = model.predict_proba(Xte)[:, 1]
preds = probs > 0.2          # not 0.5, chosen from the PR curve
The default threshold of 0.5 is rarely right on imbalanced data. The rest of this section measures whether the first line is worth writing at all.

Weighting and resampling are usually presented as alternatives. They are close to the same operation: balanced class weights multiply the rare class's contribution to the loss by the inverse of its frequency, and duplicating each rare row that many times adds it to the loss that many times. Written out side by side and measured, they land in the same place.

import numpy as np

# (a) class weights: one argument
bal = LogisticRegression(max_iter=2000, class_weight="balanced").fit(Xtr, ytr)

# (b) random oversampling, by hand, on the training fold only
pos, neg = np.where(ytr == 1)[0], np.where(ytr == 0)[0]
up = np.random.default_rng(0).choice(pos, len(neg), replace=True)   # draw with replacement
idx = np.concatenate([neg, up])
over = LogisticRegression(max_iter=2000).fit(Xtr[idx], ytr[idx])

s_bal  = bal.predict_proba(Xte)[:, 1]      # scores, kept for the comparison below
s_over = over.predict_proba(Xte)[:, 1]

#                       accuracy   precision  recall    tp    fp      fn
# plain, threshold 0.5   0.99858     1.000     0.012     1       0     85
# class_weight=balanced  0.76190     0.005     0.756    65   14265     21
# oversampled 1:1        0.76205     0.005     0.756    65   14256     21
The two rebalancing methods agree to within nine false positives out of 14,000. Pick whichever is less code; there is no third effect to discover.

Both moved recall from 0.012 to 0.756, which looks like the problem solved. It is not. They also produced 14,265 alerts to catch 65 real cases, so 99.5 per cent of everything sent to a human is wrong. Whether that is progress depends entirely on what an alert costs, which is a question neither method asked.

The threshold does the same job, better#

The plain model was never unable to find the positives. It ranked them fine and then applied a cut-off of 0.5, which is a number with no connection to anything. Move it, and the untouched model covers the whole range of behaviours the rebalanced ones offered.

for t in (0.5, 0.1, 0.01, 0.005, 0.002):
    tn, fp, fn, tp = confusion_matrix(yte, s >= t).ravel()
    print(t, tp, fp, fn)

# threshold  precision  recall    tp      fp    fn
#   0.500      1.000     0.012     1       0    85
#   0.100      0.938     0.174    15       1    71
#   0.010      0.035     0.500    43    1185    43
#   0.005      0.015     0.605    52    3455    34
#   0.002      0.006     0.733    63   10182    23   <- compare with the rebalanced rows
Same model, same weights, no retraining. At threshold 0.002 it has similar recall to the rebalanced models with 4,000 fewer false alarms.

So the comparison is not close. At roughly the same recall the plain model with a moved threshold produces 10,182 false positives where the rebalanced ones produced 14,265, which is 29 per cent less work for the team reading the alerts. And the underlying ranking, which is what the threshold is slicing, was measurably damaged by rebalancing.

from sklearn.metrics import roc_auc_score, average_precision_score

for name, sc in [("plain", s), ("balanced", s_bal), ("oversampled", s_over)]:
    print(name, round(roc_auc_score(yte, sc), 4), round(average_precision_score(yte, sc), 4))

# plain         roc_auc 0.8451    average_precision 0.2332
# balanced      roc_auc 0.8460    average_precision 0.1751
# oversampled   roc_auc 0.8463    average_precision 0.1731
ROC AUC says all three are the same model. Average precision, the metric that respects the base rate, says rebalancing cost a quarter of the useful signal.

That is the finding worth carrying away, because it is the opposite of the usual advice. Rebalancing changes where the model puts its decision boundary, which the threshold already controls for free, and in doing so it distorts the fit in a way that can make the ranking worse. Try the threshold first. Reach for weights when the model genuinely cannot separate the classes at any threshold, which the precision-recall curve will tell you.

Rebalancing destroys the probabilities

A model trained with balanced weights on this data predicts a mean probability of 0.357 where the true rate is 0.00143, overstating the risk by a factor of 250. The rank order survives; the numbers do not. If anything downstream multiplies the score by a transaction value, or shows a percentage to a person, rebalance and you are now lying by two orders of magnitude. Calibrate afterwards, or do not rebalance.

Resample training only, never validation

If you balance the validation set you are measuring on a distribution that does not exist, and the reported performance will not survive contact with production. Resample the training fold; evaluate on the real distribution.

Optimise the business quantity#

Precision and recall are proxies. What usually matters is a cost: a missed fraud costs the transaction value, a false positive costs a review. Expressing that directly gives you an unambiguous threshold rather than an argument about metrics.

def expected_cost(y_true, probs, threshold, miss_cost, review_cost):
    preds = probs > threshold
    misses = ((y_true == 1) & ~preds).sum()
    reviews = preds.sum()
    return misses * miss_cost + reviews * review_cost

# A missed case costs 500, a reviewed alert costs 8. Sweep and take the minimum.
grid = [0.001, 0.002, 0.005, 0.01, 0.05, 0.1, 0.5]
for t in grid:
    print(t, expected_cost(yte, s, t, 500, 8))
best = min(grid, key=lambda t: expected_cost(yte, s, t, 500, 8))
print("cheapest threshold", best)

# 0.001  159216
# 0.002   93460
# 0.005   45056
# 0.01    31324
# 0.05    34972
# 0.1     35628
# 0.5     42508
# cheapest threshold 0.01
The threshold that minimises cost is 0.01, not 0.5 and not the 0.002 that maximised recall. Change either cost and it moves, which is the point: the right cut-off is a property of the business, not of the model.

Done properly that needs three splits, not two: train the model, choose the threshold on validation, and confirm on a test set that neither has touched. Choosing the threshold on the same data you report is the same overfitting as any other hyperparameter, and with a rare class it is worse, because the choice is being made from a handful of positives.

from sklearn.metrics import precision_recall_curve

Xtr, Xtmp, ytr, ytmp = train_test_split(X, y, test_size=0.4, stratify=y, random_state=0)
Xva, Xte, yva, yte = train_test_split(Xtmp, ytmp, test_size=0.5, stratify=ytmp, random_state=0)

m = LogisticRegression(max_iter=2000).fit(Xtr, ytr)
sva, ste = m.predict_proba(Xva)[:, 1], m.predict_proba(Xte)[:, 1]

MISS, REVIEW = 400, 5                       # pounds per missed case, per alert reviewed
_, _, thr = precision_recall_curve(yva, sva)
best_t = min(thr, key=lambda t: MISS * ((sva < t) & (yva == 1)).sum() + REVIEW * (sva >= t).sum())
print(round(best_t, 5))                      # 0.01656   chosen on validation only

# confirmed on the held-out test set:
#   threshold 0.5    alerts      1   caught  1/57   missed 56   cost 22,405
#   cost-chosen      alerts    308   caught 26/57   missed 31   cost 13,940
A 38 per cent cost reduction from changing one number. The model was never retrained, and the whole exercise runs in under a minute.

The two costs only have to be right relative to each other; scaling both changes nothing. That is useful in practice because nobody can tell you what a missed case costs, but almost everyone can tell you whether it is worth ten reviews or a thousand. Ask the question that way and you will get an answer.

At 1000 to 1, optimise the ranking#

Far enough down the table at the top of this section, the framing changes. Nobody is going to act on 14,000 alerts, so the model is not deciding anything; it is ordering a queue that a team will work down until they run out of day. The right question stops being where the threshold goes and becomes how much of the rare class sits in the top k, where k is a staffing number rather than a modelling one.

for k in (100, 500, 2000):
    top = np.argsort(-ste)[:k]
    print(k, round(yte[top].mean(), 3), round(yte[top].sum() / yte.sum(), 3))

# review top   100:  precision 0.170   recall 0.298
# review top   500:  precision 0.058   recall 0.509
# review top  2000:  precision 0.018   recall 0.649
Precision at k, against a base rate of 0.0014. The top 100 are 120 times richer in positives than a random 100, which is the honest statement of what the model is worth.

Read those rows as an operational choice. One hundred reviews a day catches 30 per cent of cases and one alert in six is real, which a team will tolerate. Two thousand reviews catches 65 per cent and 98 per cent of the work is wasted, which no team will tolerate for long, and the usual outcome is that they stop reading the alerts entirely. The model that maximises recall is frequently the model that gets switched off.

Metric                  Use it when                              Why not otherwise
accuracy                never, with a rare class                 the majority class answers it
ROC AUC                 comparing models during development      flattered by the huge TN count
average precision       comparing models, rare class             respects the base rate
precision at k          a fixed review capacity                  matches what the team does
recall at fixed alerts  a fixed review capacity                  the same trade, stated the other way
expected cost           the costs are roughly known              turns the argument into arithmetic
lift over base rate     explaining value to a non-specialist     "120 times better than random"
Worth memorising. The interview answer is that you would ask what happens to a positive prediction, and choose from this table based on the reply.

Not enough positives to measure

With 201 positives in the training set, every number here has a wide confidence interval, and a difference of two percentage points of recall is eight cases and probably noise. Bootstrap the test set to get an interval before believing any comparison, use stratified cross-validation rather than a single split, and accept that below roughly fifty positives in the evaluation set you cannot distinguish two decent models at all. Collecting more labelled positives is then a better use of a week than any modelling change.

Where people go wrong

Balancing the test set. Performance then looks strong on a distribution that does not exist, and drops immediately in production.

Build this: Take an imbalanced dataset and compare class weights, SMOTE and threshold tuning. Report precision, recall and expected cost for each, and note which gave the most improvement per unit of effort.

Open this section on its own page
Section 2 of 2, 4h

Metrics and thresholds, worked through

By the end: You can read a precision-recall trade-off, choose a threshold from costs, and check whether the model's probabilities can be trusted.

  • A confusion matrix with real numbers
  • Precision-recall curves
  • Why ROC AUC flatters rare-event models
  • Choosing a threshold from costs
  • Calibration

A classifier produces a score; the threshold turns it into a decision, and choosing it is a business question rather than a modelling one. This section works through it with real numbers on a rare-event problem: reading a confusion matrix, why ROC AUC flatters a model when the positive class is 0.2% of the data while precision-recall describes what the team reviewing alerts will actually experience, and how to pick the threshold from the cost of a miss against the cost of a review. It ends on calibration, which you need the moment anything downstream treats the score as a real probability.

The numbers#

100,000 transactions, 200 are fraud (0.2%). Threshold 0.5:

                   predicted fraud   predicted fine
actual fraud             150                50
actual fine              850            98,950

precision = 150 / (150 + 850)  = 15%   (of alerts, how many are fraud)
recall    = 150 / (150 + 50)   = 75%   (of fraud, how much we caught)
accuracy  = 99,100 / 100,000   = 99.1% (a model saying 'fine' always gets 99.8%)

Read the two definitions carefully, because the difference between them is the whole section. Precision divides by the number of alerts, so it answers the reviewer's question: if this fires, how often is it real. Recall divides by the number of real cases, so it answers the risk question: of everything that happened, how much did we see. They are computed from the same confusion matrix and they move in opposite directions as the threshold slides, which is why quoting one without the other says nothing.

Every threshold is a different model#

The classifier outputs a score. The threshold turns it into a decision. Raising it gives fewer alerts, higher precision and lower recall. A precision-recall curve shows every trade-off at once, and the area under it summarises the model across thresholds.

from sklearn.metrics import precision_recall_curve, average_precision_score

precision, recall, thresholds = precision_recall_curve(y_val, scores)
print("PR AUC (average precision):", average_precision_score(y_val, scores))

One detail about the curve that catches people: precision is not monotonic along it. Raising the threshold usually raises precision, but it can drop when the next few cases excluded happen to be real, and at the extreme right-hand end the curve is computed from a handful of examples and is mostly noise. Do not choose an operating point from the ragged end of a PR curve; it will not reproduce.

A concrete curve, base rate 0.14%, 60,000 rows:

threshold   precision   recall     what it means operationally
  0.0508      0.333     0.246      1 alert in 3 is real, you catch a quarter
  0.0200      0.107     0.404      1 in 9 is real, you catch 40%
  0.0100      0.041     0.544      1 in 24 is real, you catch half
  0.0050      0.016     0.649      1 in 62 is real
  0.0020      0.006     0.789      1 in 164 is real, you catch four fifths

average precision 0.221        a random model would score 0.0014
The right-hand column is the version to take to the team that has to work the queue. Nobody outside the model can act on a precision of 0.041.

Average precision has a property that makes it the right summary here: its random baseline is the base rate itself. So 0.221 against a base rate of 0.0014 means the model is roughly 150 times better than guessing, and that ratio stays meaningful as the base rate changes. ROC AUC has a fixed random baseline of 0.5 regardless of imbalance, which is precisely why it cannot tell you how hard your problem is.

Why ROC AUC can mislead here#

ROC curves use the false positive rate, which divides by the huge number of legitimate transactions. 850 false alarms out of 99,800 is a false positive rate under 1%, which looks excellent, while analysts are drowning in alerts that are 85% wrong. For rare events, precision-recall tells you what the team will experience.

The same three operating points, described both ways, is the clearest version of this. Nothing differs but the denominator.

threshold   false positive rate   precision   recall
  0.010           0.0198              0.0350      0.500
  0.005           0.0577              0.0148      0.605
  0.002           0.1699              0.0061      0.733

At threshold 0.01: "a 2% false positive rate" and "96.5% of alerts are wrong"
are the same sentence about the same model.
FPR divides by 59,914 negatives; precision divides by 1,228 alerts. The first number is an accident of how many negatives exist.
print(round(roc_auc_score(yte, s), 4))             # 0.8451   random = 0.5
print(round(average_precision_score(yte, s), 4))  # 0.2332   random = 0.00143
Both are correct descriptions of one model. Only the second one changes if the base rate changes, which is the property you want.

Pick the threshold from money#

COST_MISSED_FRAUD = 400    # average loss per missed fraud
COST_REVIEW       = 5      # analyst time per alert

best = min(
    thresholds,
    key=lambda t: COST_MISSED_FRAUD * ((scores < t) & (y_val == 1)).sum()
                + COST_REVIEW       * (scores >= t).sum(),
)
Do this on validation data, then confirm on a held-out set.

Can you trust the probabilities?#

A model is calibrated if, among cases it scores at 0.3, about 30% are positive. Resampling and class weights, common fixes for imbalance, distort probabilities. If anything downstream uses the number as a probability, such as expected loss, calibrate on held-out data and check with a reliability curve.

from sklearn.calibration import CalibratedClassifierCV, calibration_curve

calibrated = CalibratedClassifierCV(model, method="isotonic", cv=5).fit(X_train, y_train)
frac_pos, mean_pred = calibration_curve(y_val, calibrated.predict_proba(X_val)[:, 1], n_bins=10)

The quickest check is one line before any curve: compare the mean predicted probability with the observed rate. A calibrated model matches, because the average of its predictions has to equal the average outcome. Measured on the same 0.14 per cent dataset:

for name, sc in [("plain", s), ("balanced", s_bal)]:
    print(name, round(sc.mean(), 5), round(yte.mean(), 5))

# plain      mean predicted 0.00147   observed 0.00143    ratio 1.0x
# balanced   mean predicted 0.35665   observed 0.00143    ratio 248.8x
One line, no bins, no plot. If the ratio is not near 1 the model is not calibrated and nothing downstream may treat the score as a probability.

A reliability curve is the same idea per bucket: sort by score, cut into groups, and compare what was predicted with what happened. Quantile bins rather than equal-width bins, because with a rare class every equal-width bin above 0.1 is empty.

Plain model, five quantile bins:

predicted    observed
 0.00005      0.00008
 0.00019      0.00058
 0.00047      0.00050
 0.00110      0.00067
 0.00555      0.00533     close on every bin: this model can be trusted as a probability
The second bin is out by a factor of three on eight observed cases, which is noise, not miscalibration. Small counts make the top and bottom bins unreliable.

Isotonic regression repairs the scale, and it is worth knowing exactly what it does and does not repair. Applied to the rebalanced model it brings the mean prediction back from 0.357 to 0.00140 against a true rate of 0.00143, which is the fix working. It does not recover the ranking that rebalancing cost.

cal = CalibratedClassifierCV(LogisticRegression(max_iter=2000, class_weight="balanced"),
                             method="isotonic", cv=3).fit(Xtr, ytr)
sc = cal.predict_proba(Xte)[:, 1]

print(round(sc.mean(), 5), round(yte.mean(), 5))          # 0.00140  0.00143   fixed
print(round(average_precision_score(yte, s_bal), 4),
      round(average_precision_score(yte, sc), 4))          # 0.1751 -> 0.1680   not fixed
Calibration is a monotonic rescaling, so it cannot change the order of the scores. Whatever ranking quality was lost upstream stays lost.

Isotonic or Platt

Isotonic fits an arbitrary increasing step function and is the better choice with enough data, roughly a thousand positives. Below that it overfits, producing flat steps where a whole range of scores maps to one probability. Platt scaling fits a one-parameter sigmoid instead, which cannot overfit in the same way and is the right default when positives are scarce, which on a rare-event problem they always are. Fit either on held-out data, never on the training set.

Where people go wrong

Using the default 0.5 threshold. For a rare class it is almost never the right one, because nothing about 0.5 relates to your costs.

Build this: Take an imbalanced dataset, train a model with class weights, plot its precision-recall curve, choose a threshold from assumed costs, and draw a reliability curve before and after isotonic calibration.

Open this section on its own page

Where you use this

A topic is easier to care about when you can see what it is for. These projects lean on it at a named stage.

Take a model from a table to an endpoint
  • Stage 5, Choose the metric, then choose the threshold: What changes when the positive class is 3% of rows, and which of the standard fixes are worth applying.

Now actually do it

Reading a topic is not learning it. These are the hours that count, and each one tells you when you are finished.

1Three approaches, measured by cost

5h

Compare class weights, SMOTE and threshold tuning on the same imbalanced data. Report precision, recall and expected cost using realistic error costs.

Done when: You chose a threshold from expected cost rather than from a metric argument.

2Balance the test set, then regret it

5h

Deliberately resample validation and record the inflated performance. Then evaluate on the real distribution.

Done when: You know the size of the inflation, which is why you will not do it again.

10 hours of practice

Words used here

3 terms this topic teaches, each with a one-line definition if one of them stopped you.

PR AUC
Area under the precision-recall curve, which unlike ROC AUC takes no credit for correctly dismissing the abundant negative class. The right summary metric when positives are rare.
Class imbalance
One class making up a small fraction of the data, so a model minimising average error learns to ignore it. Fraud, faults and disease all look like this.
SMOTE
Creating extra minority-class rows by interpolating between real ones and their neighbours, rather than duplicating them.

Interview questions

5 questions, grouped by the section that teaches the answer. An answer you have read is not an answer you can give, so use Test yourself and say it out loud first.

When one class is rare

Your classes are 99 to 1. What do you do?

Stop using accuracy, and use precision, recall and PR AUC. Try class weights first, tune the decision threshold from the precision-recall curve rather than leaving it at 0.5, and evaluate on the real distribution rather than a rebalanced one.

Should you oversample before or after splitting?

After, and only the training fold. Oversampling before splitting puts copies of the same example in both train and test, which inflates the score. The validation set must keep the real class balance.

How do you pick the decision threshold?

From the cost of each error type. Compute expected cost across thresholds using the real cost of a miss and of a false positive, and pick the minimum. That turns a metric argument into an arithmetic answer.

Metrics and thresholds, worked through

Your fraud model has 0.97 ROC AUC. The fraud team says it is useless. Who is right?

Possibly both. With fraud at 0.2%, even a low false positive rate produces far more false alerts than real fraud, so precision at the operating threshold can be very low. I would look at the precision-recall curve and at precision and alert volume at the threshold actually in use.

Then they push further

How would you choose the threshold?

From costs: the average loss from a missed fraud against the cost of reviewing an alert, plus any capacity limit on the team. Pick the threshold that minimises expected cost on validation data, then confirm on held-out data.

The team can only review 500 alerts a day. Does that change anything?

Yes. Then the question becomes which 500 to review, so rank by score and take the top 500. The metric that matters is precision in the top 500, and improving the model means pushing more fraud into that set.

What gets marked up. Strong answers explain why ROC AUC flatters rare-event models and move to precision at the operating point and costs. Treating AUC as the final answer is marked down.

What does it mean for a classifier to be calibrated, and when do you need it?

Its scores match observed frequencies: cases scored 0.3 are positive about 30% of the time. You need it whenever the score is used as a probability, such as in expected cost calculations or when showing risk to people.

These questions on their own page, one link per question

Resources

Free to watch and read, picked for this topic.