Polish review sentiment · PolEmo 2.0 · 684 test reviews

HerBERT reaches 0.986 against the baseline's 0.944

It is right on 38 reviews the baseline misses and wrong on 7 it gets; on 45 disagreements that is a real difference (p < 0.0001). What it costs is further down the page.

What the corpus score is, and how tightly it is pinned

Macro-F1 rather than accuracy, because the classes are imbalanced: answering negative to everything would score respectably on accuracy and badly here. The figure below it is the one that is usually left off — a score on 684 reviews is a sample, and the interval says how much of the third decimal is real.

classprecisionrecall F1test reviews
negative 0.939 0.953 0.946 339
neutral 0.991 0.949 0.970 118
positive 0.916 0.916 0.916 227

Macro-F1 0.944, and 95% of 2000 bootstrap resamples of the test rows fall between 0.926 and 0.961. Any comparison narrower than 0.035 is a comparison this test set cannot make.

What that is worth depends on what it is read against, so the floors are on the page rather than left to the reader's intuition: always the majority class scores 0.221 macro-F1 at 0.496 accuracy, random, matching the training prior scores 0.317 macro-F1 at 0.367 accuracy. The first of those is the whole argument for the headline metric: it is a model that has learned nothing, and accuracy still flatters it.

Confusion matrix, TF-IDF baselinepredictedtruenegativeneutralpositivenegative323016neutral31123positive181208
Rows are the true class, columns what the model answered. Shading is the share of each row, so the three classes are comparable despite differing threefold in size. The confusions are almost entirely between the two ends of the scale, not with the middle.

Is the transformer better?

modelaccuracymacro-F1interval
TF-IDF + logistic regression 0.940 0.944 0.926–0.961
HerBERT, fine-tuned 0.985 0.986 0.976–0.994

The intervals are not the comparison. Both models answer the same 684 reviews, so the question is which reviews they disagree on: HerBERT is right on 38 the baseline misses, the baseline is right on 7 HerBERT misses, and the remaining 639 they answer alike. An exact McNemar test on those 45 disagreements gives p < 0.0001.

How much of this score is data rather than model

Before asking what a transformer would add, it is worth asking what the corpus adds. The same pipeline is refit from scratch on stratified subsamples of the training split and scored on the untouched test split every time.

Macro-F1 against training size0.751.001503006001 2002 4004 8000.944 at n=5 264labelled reviews, log scale · band = spread over 5 stratified draws per size
Macro-F1 against the number of labelled reviews, on a log axis because the sizes double. The band is the spread over 5 draws at each size — where it is wide, the point is a draw of the dice rather than a property of the size. The y axis starts above zero; the floor is printed on it.

1200 labelled reviews — 23% of the corpus — already land within 0.019 macro-F1 of what all 5264 produce. The remaining 4064 annotations buy the last 1.9 points. Whether that is worth paying for is a budget question, but it is the same question as "should we fine-tune a transformer", asked where the answer is cheaper to test.

What the cheap side of that trade costs, measured rather than asserted: the full pipeline fits in 3.2 s on Intel64 Family 6 Model 158 Stepping 10, GenuineIntel (CPU), the saved model is 3.2 MB, and it classifies 5921 reviews a second. The other side of the trade, measured on the same task rather than described: fine-tuning HerBERT took 49 minutes on a GeForce GTX 1050 — 927× the training time, over 4 epochs of the same 5264 reviews, and it wants that card to serve as well as to train. Whether the difference in score is worth the difference in bill is the question this page exists to put numbers on.

Where a bag of words slips

The corpus score says how the model does on reviews like the ones it was trained on. It says nothing about the phenomena a lexical model is supposed to be bad at. This section used to assert that negation and irony are its weak points and illustrate the claim with three unscored sentences; it now measures it.

80 sentences, written for this page in the four domains PolEmo draws from, plus 158 derived by re-spelling the same sentences — diacritics stripped, and one adjacent-character swap in the longest word. No PolEmo text appears here; the corpus is non-commercial and is not redistributed by this repository.

Challenge set, answered correctly (correct of total)plain13 / 20 (65%)negation13 / 20 (65%)sarcasm11 / 20 (55%)contrast11 / 20 (55%)no-diacritics47 / 78 (60%)typos47 / 80 (59%)
The track is the size of each cell, the bar what was answered correctly. Read plain first: it is the control, unambiguous sentiment with no negation, irony or pivot, and everything below it is only interesting relative to that. The two derived cells re-score the same sentences, so they belong beside the base cells rather than added to them.
cellwhat it holdscorrect
plain unambiguous sentiment, no negation, irony or pivot — the control cell 13 / 20
negation sentiment carried by a negation cue, in both directions 13 / 20
sarcasm ironic praise against genuine praise in the same register 11 / 20
contrast a pivot (ale / jednak / mimo) after which the sentiment lands 11 / 20
no-diacritics every base case with Polish diacritics stripped 47 / 78
typos every base case with one adjacent-character swap in its longest word 47 / 80

The cells are built so that no single answer can win them. Every ironic sentence has a genuine counterpart in the same enthusiastic register, down to minimal pairs — "bateria wytrzymała całe trzy godziny" against "całe trzy dni" — because a probe made only of ironic praise would be scored 20 of 20 by a model that answers negative to everything. The negation cell carries cues in both directions for the same reason.

sentencecellshould beanswered
Zamówienie dotarło uszkodzone, a reklamację odrzucono. plain negative neutral
Zajęcia prowadzone chaotycznie, materiał kompletnie przestarzały. plain negative positive
Hotel leży dwa przystanki od dworca, śniadanie w formie bufetu. plain neutral positive
Wizyta trwała dwadzieścia minut, lekarz zlecił podstawowe badania krwi. plain neutral negative
Kurs obejmuje dwanaście spotkań po dziewięćdziesiąt minut, materiały w PDF. plain neutral positive
Przesyłka przyszła kurierem w trzy dni robocze, opakowanie standardowe. plain neutral negative

6 of 96 misses, in the order they were scored rather than picked for effect.

Where the misses go

A count of correct answers cannot tell a model that misreads irony apart from one that answers negative to everything — both score the same. The direction of the errors can, which is why it is a column rather than a sentence.

cellshould beansweredtimes
no-diacritics negative positive 12
typos negative positive 11
sarcasm negative positive 8
no-diacritics positive negative 7
typos neutral negative 6
typos positive negative 6
no-diacritics neutral positive 5
typos neutral positive 5

The eight most frequent of 26 distinct should-be/answered pairs, over all 96 misses including the derived cells.

Is that about length, or about our sentences?

The section above is the page's own claim, and it rests on sentences we wrote. The obvious objection is that the probe measures our writing rather than its length. PolEmo is where to put that question: nobody here chose its reviews, and the same predictions that produced the headline score can be cut by how long each one is. Whether the corpus is in a position to answer is the first thing the cut has to establish.

Macro-F1 by review length (macro-F1)< 25 words0.667 n=625–49 words0.621 n=3050–99 words0.964 n=213100–199 words0.929 n=352200+ words0.953 n=83
The same 684 test reviews, grouped by word count. Macro-F1 inside a group averages only the classes that occur in it — a group holding no neutral reviews would otherwise take a hard zero for that class and report its own composition as the model's failure. Groups under 50 reviews are muted: a direction, not a score. The median review in this split is 119 words, and the shortest is 5.

The corpus cannot settle it. PolEmo is made of written reviews — the median one in this split is 119 words — and only 3 of its 684 test reviews are as short as the probe's sentences (9 words at the median). The length the claim is about is a length this corpus barely contains, which is why the sentences had to be written rather than sampled. Across the lengths it does cover, counting only groups of at least 50 reviews, the score moves by -0.011 macro-F1 between the shortest and the longest of them — inside the interval around the score itself, so not a direction at all. So the honest reading of the section above is narrower than "short text breaks it": on sentence-length Polish reviews of the kind this corpus does not contain, the model is not the 0.94 model. What separates the two — length, register, or the distance between a review and a constructed sentence — this page cannot take apart, and does not claim to.

What the model keys on

A linear model has two advantages over a transformer, and only one of them is the compute bill. These are the 10 heaviest coefficients per class — the model's own reasons, not an explanation reconstructed after the fact. They are weights, not review text.

negative

Heaviest terms for negative (weight)nie5.39nie polecam4.70niestety3.78odradzam3.03brak2.80po2.48nic1.76pokoju1.73bo1.71za1.67

neutral

Heaviest terms for neutral (weight)prof2.93proc2.93czy2.17polsce1.91pap1.88będzie1.87hoteli1.76także1.631.62oraz1.60

positive

Heaviest terms for positive (weight)bardzo4.34polecam4.12zawsze2.09czyste1.68doktor1.64miła1.63super1.59jestem1.55doktora1.54wszystko1.50

Worth reading against the cells above. Where a class's heaviest terms are evaluative, the model has learned sentiment; where they are topical — the vocabulary of the domains those reviews happen to come from — it has learned to recognise the subject matter, and will carry that habit onto any sentence about the same subject. A single token weighted for one class also cannot be conditioned on what follows it, which is what the negation cell measures.

Knowing when not to answer

A system that can run both models does not have to choose one. The cheap model answers what it is sure about and hands the rest on, and the bill is then set by how much gets handed on. That only works if the model's confidence means something, so it is measured before it is used.

Confidence against accuracy0101the model's confidenceaccuracyn=5n=54n=79n=121n=171n=178n=76one point per populated confidence bin; the dashed line is perfect calibration
One point per populated confidence bin: what the model claimed, against how often it was right. Expected calibration error 0.209. The points sit above the diagonal — the model is under-confident, right more often than it says. That is not the harmless direction it sounds like: a rule written as "hand on anything below 0.7" would escalate bands this model answers perfectly.

Which is why the operating point comes from the table below rather than from the confidence number itself. Reviews are handed on in order of the model's own confidence, least sure first, and the threshold column is the lowest confidence still answered — the number that would go into a configuration file.

handed onreviewskept thresholdmacro-F1 on what it keeps
0% 0 684 0.376 0.944
5% 34 650 0.471 0.963
10% 68 616 0.519 0.974
20% 137 547 0.598 0.991
30% 205 479 0.661 0.996

The first row is the reference: no deferral, the whole test set. That the score climbs as reviews are set aside is the evidence that the confidence ranks anything at all — had it stayed flat, no threshold built on it would work, whatever the calibration figure said.

And with the transformer behind it

The same ordering, but the reviews handed on are answered by HerBERT instead of being set aside. This is the row of the page that decides an actual system: the cheap model runs on everything, the expensive one on the fraction the cheap one is least sure of, and the GPU bill is that fraction rather than all of it.

handed to HerBERTreviews macro-F1, whole test setshare on the GPU
0% 0 0.944 0%
5% 34 0.961 5%
10% 68 0.972 10%
20% 137 0.980 20%
30% 205 0.983 30%

Read the 20% row. Sending that share of reviews to the GPU — the 137 the baseline is least sure about — reaches 0.980 macro-F1, which is 85% of everything the transformer adds over the baseline, for 20% of the traffic it would otherwise have to serve. That rate is not picked for the number it produces: it is the smallest one in the table that closes at least 80% of the gap.

Two things this table is not. It is not a latency argument — the deferred reviews still wait for the transformer, so the tail gets slower, not faster. And the confidence it routes on is the baseline's own, which the calibration figure above says is not a probability; it works here because it ranks, and the ranking is what the first table measured.

Method, and what it cannot tell you

Data. PolEmo 2.0 (CLARIN-PL), the all_text configuration, pinned at revision 802e35d2b12b so the loader script that runs is one that was audited. The amb class is dropped rather than folded into neutral: ambiguous is not a point on the negative–positive axis, and merging it would corrupt the class it was merged into. That leaves 5264 training, 668 validation and 684 test reviews.

No leakage, and no tuning against the test split. The vectorizer is fit inside the pipeline, on the training fold only. Hyperparameters are fixed defaults in config.py; the validation split is deliberately not tuned against, so the test figures stay an estimate rather than a selection.

Limits. One corpus, of traditional written reviews from four domains — this is not a general Polish sentiment API, and the probe above is the evidence for that rather than a caveat about it. The probe itself is 80 sentences: enough to show that a gap exists, not to quantify it. The compute figures are measured on one CPU and quoted with the machine beside them. Nothing here says what HerBERT does, because HerBERT has not run.

What is committed. Numbers only — metrics, intervals, coefficients, timings and per-row predictions as label integers. No review text is stored in this repository; PolEmo is CC BY-NC-SA and is downloaded on demand.

The configuration behind every number

Printed from the constants the code actually uses, not described. A setting that is quoted from config.py cannot drift away from the run that produced the figures above.

dataset
name        clarin-pl/polemo2-official
config      all_text
revision    802e35d2b12bae84bb07911d841e8f046dc2fcef
dropped     amb  (ambiguous — never merged into another class)
labels      negative, neutral, positive
license     CC BY-NC-SA 4.0 — downloaded on demand, never redistributed here
baseline
tfidf       ngram_range=(1, 2)  max_features=50000
            min_df=2  sublinear_tf=True
logreg      C=1.0  max_iter=1000  class_weight='balanced'
seed        42
fitted inside one Pipeline, so the vectorizer never sees the test split
herbert
model       allegro/herbert-base-cased
epochs      4  lr=2e-05  weight_decay=0.01
batch       4 x 4 accumulated = 16 per optimizer step
max_len     256
seed        42
the split batch is a memory accommodation, not a different hyperparameter: what
reaches the optimizer is the same 16 examples a larger card would pass in one go
uncertainty
bootstrap   2000 resamples of the test rows, 95% percentile interval
paired test exact McNemar on the reviews the two models answer differently
seed        42 — the interval is reproducible from the committed predictions
learning-curve
sizes       150, 300, 600, 1200, 2400, 4800, then the full corpus
seeds       0, 1, 2, 3, 4 per size
sampling    stratified — an unstratified draw of 150 rows can miss the neutral class outright
deferral
rates       0%, 5%, 10%, 20%, 30% of the test set, least confident first
calibration 10 equal-width confidence bins
probe floor 20 cases before a cell is read as a rate
commands
python -m pl_review_sense.baseline_train   # trains, writes metrics + predictions
python -m pl_review_sense.analysis         # intervals, curve, probe, deferral
python -m pl_review_sense.site             # rebuilds this page into docs/

python -m pl_review_sense.herbert          # the GPU run behind the comparison
notebooks/herbert_colab.ipynb              # the same run on a free Colab card