User Tools

Site Tools


statistics:annotation

This is an old revision of the document!


Annotation and Validation

Every measurement on this site ends in labels. This third-party domain is advertising; this cookie is analytics; this banner is a dark pattern; this site is News. Something assigned those labels — a filter list, a vendor API, a regex you wrote on a Tuesday, three hundred rows you coded yourself, or since 2024 a language model — and the whole result rests on the claim that the labels mean what their names say.

This page is about the second half of that: how you show the labels are right, and what “right” can even mean when the things being labelled are web pages. It is not about kappa — Interrater agreement owns the coefficients and the reliability of hand coding, and this page does not repeat any of it. It is about the design of the check: what you validate against, whether that source is independent of the thing you are checking, why a held-out set drawn from a crawl is usually not held out, and what an LLM annotator owes you that a human one does not.

The short version of the corpus evidence is that this is the least-reported part of the pipeline. Papers say which tool they used and what they found; a large minority never say whether the labels were checked, and the targets where they say it least are exactly the web ones.

Three papers in ten that classify anything never report validating any of it, and the reporting has not improved in fifteen years. The worst-reported targets are cookies (32.1%), IP addresses (33.6%) and domains (41.0%) — all far below the 70.1% base rate. And the classifiers people validate least are the ones they did not build.

Of the 5,859 papers extracted from seven security, privacy and measurement venues (2010–2026), 4,439 classified or labelled something and 3,318 hand-coded something; 2,927 did both. Of the 4,439:

  • 3,111 (70.1%) report at least one of the four validation kinds the extraction distinguishes. 1,328 (29.9%) report none for any classifier they used.
  • The often-quoted 42.4% is a different, looser quantity: the share with at least one classifier carrying no validation. Rows overlap, so it is not the share of papers that validated nothing. Both are below.
  • 84.8% of papers using supervised ML validate it, and 83.6% of papers using an LLM do. 27.7% of papers using a curated database do, 30.3% of those using a third-party service, and 32.3% of those using a blocklist.
  • 72.9% name a ground-truth source at all; of those that do, 52.0% name human annotation of a sample.
  • The share reporting validation was 63.6% in 2010 and 70.6% in 2026. There is no trend to speak of.

Every figure here is a claim about those seven venues. 2025 and 2026 are provisional venue-years. See Methodology and Limitations of These Figures.

What to Read First

  • [1Arp, Daniel; Quiring, Erwin; Pendlebury, Feargus; Warnecke, Alexander; Pierazzi, Fabio; Wressnegger, Christian; Cavallaro, Lorenzo; Rieck, Konrad (2022): "Dos and Don'ts of Machine Learning in Computer Security", in: Proceedings of the USENIX Security Symposium. (Link)] (USENIX Security 2022) — Dos and Don'ts of Machine Learning in Computer Security. Ten pitfalls, reviewed across 30 papers from the top-four security venues over ten years. Three of the ten (sampling bias, label inaccuracy, data snooping) are this page's subject, and the paper is the reason a reviewer now asks. Read §2 and §3 before you draw a test set.
  • [2Pendlebury, Feargus; Pierazzi, Fabio; Jordaney, Roberto; Kinder, Johannes; Cavallaro, Lorenzo (2019): "TESSERACT: Eliminating Experimental Bias in Malware Classification across Space and Time", in: Proceedings of the USENIX Security Symposium. (Link)] (USENIX Security 2019) — TESSERACT. What happens when the split is drawn without respect to time or to the population's structure: “published F1 scores of up to 0.99”, and most of it is bias. The mechanism generalises off malware.
  • [3Ruth, Kimberly; Fass, Aurore; Azose, Jonathan; Pearson, Mark; Thomas, Emma; Sadowski, Caitlin; Durumeric, Zakir (2022): "A World Wide View of Browsing the World Wide Web", in: Proceedings of the ACM Internet Measurement Conference, pp. 317-336. (DOI)] (IMC 2022) — the shortest worked example of a defensible validation in this corpus: stratified sample, ten sites per category, keep only categories above 80% accuracy, say what was dropped. One paragraph, and it is the paragraph to imitate.
  • [4Munir, Shaoor; Siby, Sandra; Iqbal, Umar; Englehardt, Steven; Shafiq, Zubair; Troncoso, Carmela (2023): "CookieGraph: Understanding and Detecting First-Party Tracking Cookies", pp. 3490–3504. Association for Computing Machinery, New York, NY, USA. (DOI) (Link)] (CCS 2023) — the one sentence most web-measurement papers are missing: “We ensure that there is no overlap in the websites used for training and testing in each fold.” See The Held-Out Set That Is Not Held Out.
  • [5Pangakis, Nicholas; Wolken, Samuel; Fasching, Neil (2023): "Automated Annotation with Generative AI Requires Validation". arXiv:2306.00176. (Link)] and [6Törnberg, Petter (2024): "Best Practices for Text Annotation with Large Language Models", Sociologica 18(2):67-85. (DOI)] — the argument, and then the protocol, for validating LLM annotations against humans rather than assuming them.

Four Ways to Validate, and What Each One Is Worth Here

The extraction distinguishes four. They are not interchangeable, and which is available to you is decided by whether a labelled set of your items already exists — which, for most web-measurement targets, it does not.

Kind Papers Share of 4,439 What it actually establishes
manual-validation 2,265 51.0% A person re-labelled a sample and you report the agreement. Usually the only one available, because for cookies, requests, banners and third-party domains there is no pre-existing labelled corpus. Its weakness is sample size, not principle.
comparison-to-other-method 791 17.8% Your labels agree with another tool's. This measures agreement, not correctness — two filter lists derived from the same community's reports will agree and both be wrong. Useful as a floor, never as a ceiling.
held-out-test-set 547 12.3% Performance on items withheld from training. Only tells you about the labels you already had, and only if the split respects the structure of the data — see below.
cross-validation 314 7.1% The same, k times. Inherits the same leakage problem k times over.
none-reported 1,881 42.4% The paper reports no check.
not-applicable (sentinel) 1,160 26.1% The extraction could not find a validation step and did not think one was called for. Read as “no validation reported”, not as “validation was unnecessary”, unless you check the paper.

Rows overlap: the field is multi-valued and a paper with three classifiers appears in three rows. Collapsing to one verdict per paper:

Paper-level verdict Papers Share of 4,439
Validates at least one classifier 3,111 70.1%
Validates nothing — at least one tuple explicitly none-reported 866 19.5%
Validates nothing — every tuple is the not-applicable sentinel 462 10.4%
Validates nothing, either way 1,328 29.9%

“42.4% report no validation” and “29.9% validate nothing” are both true and they are different claims. The first counts papers with at least one unvalidated classifier and is the number this wiki's roadmap carried; the second counts papers where nothing was validated. Quote whichever you mean, and name which. A page that quotes the 42.4% as “of the papers that classified anything, that share reports no validation” has overstated it by twelve points.

The classifiers nobody validates are the ones they did not build

Split by what did the classifying. The share is of papers using that method whose tuple for that method carries one of the four real validation values — so a paper that validates its own model but not the blocklist it also used counts as validated in one row and not the other.

classification.method Papers Validates
supervised-ml 1,087 84.8%
llm 177 83.6%
static-analysis 472 76.5%
dynamic-analysis 300 73.3%
graph-analysis 154 66.2%
unsupervised-ml 320 61.9%
manual-labelling 1,381 61.0%
regex-or-signature 310 55.2%
heuristic-rules 2,183 54.9%
other 394 52.0%
blocklist 189 32.3%
third-party-service 643 30.3%
curated-database 649 27.7%
All methods (base rate) 4,439 70.1%

The ordering is the finding, and the base-rate row is why. You validate what you built and you trust what you downloaded. The three bottom rows — blocklists, third-party services, curated databases — are precisely EasyList, Disconnect, VirusTotal, Cookiepedia and the vendor categorisation APIs, and they are the instruments a web measurement leans on hardest. A borrowed classifier is not a validated one: it was validated, if at all, for somebody else's population, at some other time, against some other ground truth. Filter Lists and Website classification both measure how far that assumption travels, and it is not far.

classification.method agrees about 58% run-to-run between independent extraction runs, so read this table as a ranking with a clear top and bottom rather than as thirteen precise percentages. The gap between 84.8% and 27.7% is far too large to be a labelling artefact; the gap between 55.2% and 54.9% is not a gap.

…and the targets reported worst are the web ones

The same question by what was classified. The right-hand column is the page here that a reader with that target is probably on.

classification.target Papers Validates Names a ground truth Page
other 2,594 64.0% 69.3% —
vulnerability 883 71.5% 72.6% —
website-category 424 45.3% 57.3% Website classification
user-generated-text 419 71.6% 57.8% —
network-traffic 383 58.7% 58.2% —
domain 351 41.0% 59.5% —
ip-address 295 33.6% 43.1% IP classification
mobile-app 282 66.3% 75.5% Mobile and app measurement
web-request 262 48.9% 51.1% Requests
malware 160 51.9% 78.1% —
privacy-policy 102 72.5% 70.6% Policies
sdk-or-library 77 50.6% 64.9% —
email-message 54 50.0% 66.7% Email tracking
cookie 53 32.1% 56.6% Cookies
javascript 44 63.6% 59.1% Javascript
consent-notice 39 74.4% 61.5% Consent
fingerprinting-script 32 59.4% 65.6% Fingerprinting
website-popularity 16 25.0% 31.3% Website selection
dark-pattern 13 84.6% 76.9% Darkpatterns
All targets (base rate) 4,439 70.1% 72.9% —

Read the base-rate row first and the small rows last. Cookie classification, at 32.1% of 53 papers, is the worst-validated target in the corpus that has a page here, and IP addresses (33.6% of 295) and domains (41.0% of 351) are barely better; all three are far below the 70.1% base rate. The rows with fewer than about 40 papers (dark-pattern, consent-notice, javascript, fingerprinting-script, website-popularity) are counts dressed as rates and should be read as “n papers, m of them validated” — the 84.6% for dark patterns is eleven papers out of thirteen.

It has not got better

Year Papers that classified something Validates something Names a ground truth
2010–2013 364 60.2% 64.3%
2014–2017 595 66.6% 70.1%
2018–2021 1,096 72.0% 73.3%
2022–2024 1,444 72.9% 73.5%
2025–2026 (provisional) 940 69.7% 76.5%

A step up somewhere around 2018 and then flat for eight years, inside a window in which the corpus-wide share of papers releasing an artifact link rose from 46.5% to 75.9% 1). Whatever the artifact-evaluation committees changed, they did not change this.

Where Ground Truth Comes From

2,854 of the 4,439 (64.3%) name a taxonomy and 3,234 (72.9%) name a ground-truth source. The names themselves are free text — 7,114 tuples carrying 6,223 distinct lower-cased strings, so ranking exact strings is meaningless. What can be ranked is the kind of authority, because that is what decides whether the check is independent of the thing being checked. scripts/gt_fold.mjs does that fold; it is an ordered rule list and its residue is printed in full on the provenance page.

What the ground truth is Independent of the classifier? Papers Share of 3,234
Human annotation of a sample yes 1,683 52.0%
Known by construction (the experimenter set the condition) yes 350 10.8%
An existing labelled dataset or benchmark yes 287 8.9%
A third-party service's labels yes 234 7.2%
A curated list or blocklist yes 186 5.8%
Documentation, source code or a declaration yes 104 3.2%
Execution, probing or a reproducible oracle yes 88 2.7%
Another automated method's output no 74 2.3%
Another model's output no 9 0.3%
Residue — no family fits — 1,577 48.8%

The residue is large and is not hidden: 2,434 of the 7,114 tuples (34.2%) fold nowhere, and because a paper carries several tuples that touches 1,577 papers. It is a long tail: 2,068 of the 2,206 distinct unmapped strings (93.7%) appear exactly once, each naming a resource or procedure specific to one paper that no keyword family can be expected to reach — “touchpal call tags”, “indegree median of the population”, “cryptoapi-bench”, “ecg demographic labels”. A systematic 30-string sample of it was hand-classified into the family each string would have joined, and it spreads across all of them: 6 existing dataset, 6 by construction, 4 third-party service, 3 human annotation, 3 another automated method, 2 curated list, 2 execution, and 4 that the string alone cannot place. So every share in the table above is a floor, and no single family is systematically missing. The full residue and the sample's verdicts are on annotation.

Two things to take from it. First, half of all stated ground truth in this literature is a human reading a sample; there is no shortcut and the field has not found one. Second, the two non-independent families are small but not zero, and they are the ones to watch: a paper whose ground truth is “majority vote across three LLM judgments” has measured consistency, not correctness.

Independence is the property, not the label. “We validated against EasyList” is independent of a random forest and not independent of a classifier whose features were derived from EasyList. “We validated against Cookiepedia” is independent of your heuristic and not independent of a second paper that also used Cookiepedia, which is why two papers can agree on a cookie taxonomy and both be measuring Cookiepedia's coverage. Before you name your ground truth, ask what it would take for the ground truth and the classifier to be wrong in the same direction, and write that sentence into the limitations.

The Held-Out Set That Is Not Held Out

This is the failure specific to web measurement and it is almost never mentioned. A held-out test set drawn by shuffling rows is not held out when the rows come in groups. Twenty pages of one site share a CMS, a tag manager, a consent vendor and a URL scheme. Split them at random and pages of the same site land on both sides; a classifier that has memorised the site then scores against itself, and the number you report is the number you get on sites you have already seen. The unit of independence on a crawl is the site, not the page — and arguably not even the site, when five hundred sites in your sample run the same three plugins.

[1Arp, Daniel; Quiring, Erwin; Pendlebury, Feargus; Warnecke, Alexander; Pierazzi, Fabio; Wressnegger, Christian; Cavallaro, Lorenzo; Rieck, Konrad (2022): "Dos and Don'ts of Machine Learning in Computer Security", in: Proceedings of the USENIX Security Symposium. (Link)] name this family data snooping and distinguish test, temporal and selective snooping; [2Pendlebury, Feargus; Pierazzi, Fabio; Jordaney, Roberto; Kinder, Johannes; Cavallaro, Lorenzo (2019): "TESSERACT: Eliminating Experimental Bias in Malware Classification across Space and Time", in: Proceedings of the USENIX Security Symposium. (Link)] show what the temporal version alone is worth in published F1. Neither is about the web, and that is the point: the mechanism is general and the web makes it worse, because sites are more internally uniform than almost any other population this field samples.

How often does a web-measurement paper say it split by site? Of the 300 web-platform papers whose classification carries a held-out test set or cross-validation, a full-text probe for any grouped-split language returns 10 candidates, of which 7 really split or evaluate across a group — 2.3%. That is a lower bound in both directions: a paper that split by site without writing a sentence about it is invisible to any probe of this shape, and so is one that phrased it differently. What the number supports is “this is rare and almost never stated”, not “only seven papers did it”. The regex and all ten candidates with the deciding phrase are on annotation.

[4Munir, Shaoor; Siby, Sandra; Iqbal, Umar; Englehardt, Steven; Shafiq, Zubair; Troncoso, Carmela (2023): "CookieGraph: Understanding and Detecting First-Party Tracking Cookies", pp. 3490–3504. Association for Computing Machinery, New York, NY, USA. (DOI) (Link)] is the model sentence. Its ten-fold cross-validation is site-disjoint and it says so in one clause: “We ensure that there is no overlap in the websites used for training and testing in each fold.” That clause costs nothing and is the difference between a number a reviewer can use and one they cannot.

And the positive class is rare

The second half of the same problem. “3.4% of pages do X” means a 1,000-item validation sample holds about 34 positives, and a precision computed on 34 items is a range, not a value. At 34/34 correct the 95% Wilson interval is still 10 points wide; at 25/34 it is 29 points wide. A bare point estimate from a sample that size is not a measurement, and the fix is one line of arithmetic rather than more annotation.

The same applies to coverage. Items the classifier refused, returned nothing for, or answered outside your taxonomy are a result — they are the closest thing you have to a measurement of how well the instrument understood the task. Folding them into the negatives inflates precision and deflates recall at the same time, and dropping them silently changes the denominator of everything downstream.

Code

The script below does the three things this section is about and nothing else: it splits by a grouping column instead of by row, it reports precision and recall with Wilson intervals rather than point estimates, and it refuses to let the residue disappear. It is deliberately narrow — for the stratified-sample-and-blind-double-annotation protocol, use category_validation.py on Validating Your Labels, which is the complementary half and is not repeated here.

holdout_by_site.py
#!/usr/bin/env python3
"""Score a label validation the way a web measurement makes you score it.
 
Two things go wrong when you validate labels over crawled pages, and neither is
a statistics-textbook problem:
 
  1. YOUR HELD-OUT SET IS NOT HELD OUT. Draw it by shuffling rows and pages
     from the same site land on both sides of the split. A classifier that has
     memorised one site's template then scores against itself. The unit of
     independence is the site, not the page -- and usually not even the site,
     if the sites share a CMS, a tag manager or a consent vendor.
 
  2. THE POSITIVE CLASS IS RARE. "3.4% of pages do X" means a 1,000-item sample
     contains about 34 positives, and a precision computed on 34 items is a
     range, not a number. Report the interval or do not report the precision.
 
And one thing that is not an error at all but is usually deleted: items the
classifier refused, returned nothing for, or answered outside your taxonomy.
That residue is a result. This script will not let you drop it silently.
 
    # score your own labels
    python3 holdout_by_site.py score labels.csv --positive tracking --seed 20260911
 
    # show what a page-level split does to a site-structured population
    python3 holdout_by_site.py demo --seed 20260911
 
    # verify the arithmetic (no dependencies to trust)
    python3 holdout_by_site.py selftest
 
`labels.csv` is `item,site,truth,prediction` with one row per labelled item.
Use the literal prediction `none` for items the classifier produced no usable
answer for. `truth` is your hand label; leave it empty for items you did not
hand-label and they are excluded from scoring (and counted, and reported).
 
Standard library only: no numpy, no scipy, no sklearn, no network.
"""
 
import argparse
import csv
import hashlib
import math
import random
import sys
from collections import Counter, defaultdict
 
Z95 = 1.959963984540054  # two-sided normal quantile at 95%
 
 
def wilson(k, n, z=Z95):
    """Wilson score interval for a binomial proportion.
 
    Preferred over the Wald interval here because the counts on a rare class
    are small and Wald gives (0.0, 0.0) at k=0, which reads as certainty.
    """
    if n == 0:
        return (float("nan"), float("nan"))
    p = k / n
    d = 1 + z * z / n
    centre = (p + z * z / (2 * n)) / d
    half = (z / d) * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
    return (max(0.0, centre - half), min(1.0, centre + half))
 
 
def split_by_site(rows, frac, seed):
    """Grouped split: every item of a site goes to the same side.
 
    Hashing the site name rather than shuffling it means the split is stable
    when you add sites later -- a site keeps its side. That matters for a
    longitudinal crawl, where the alternative is a test set that silently
    changes composition between waves.
    """
    train, test = [], []
    for r in rows:
        h = hashlib.sha256(f"{seed}:{r['site']}".encode()).digest()
        u = int.from_bytes(h[:8], "big") / 2**64
        (test if u < frac else train).append(r)
    return train, test
 
 
def split_by_item(rows, frac, seed):
    """The split almost every paper actually does. Here to be compared against."""
    rng = random.Random(seed)
    shuffled = list(rows)
    rng.shuffle(shuffled)
    cut = int(len(shuffled) * frac)
    return shuffled[cut:], shuffled[:cut]
 
 
def score(rows, positive):
    """Precision / recall on `positive`, with the residue kept visible.
 
    An item whose prediction is `none` is NOT a negative prediction. It is an
    abstention, and folding it into the negatives quietly inflates precision
    while deflating recall. It gets its own counts.
    """
    labelled = [r for r in rows if r["truth"]]
    unlabelled = len(rows) - len(labelled)
    residue = [r for r in labelled if r["prediction"] == "none"]
    scored = [r for r in labelled if r["prediction"] != "none"]
 
    tp = sum(1 for r in scored if r["prediction"] == positive and r["truth"] == positive)
    fp = sum(1 for r in scored if r["prediction"] == positive and r["truth"] != positive)
    fn = sum(1 for r in scored if r["prediction"] != positive and r["truth"] == positive)
    fn_residue = sum(1 for r in residue if r["truth"] == positive)
 
    prec_n, rec_n = tp + fp, tp + fn
    return {
        "items": len(rows),
        "unlabelled": unlabelled,
        "labelled": len(labelled),
        "residue": len(residue),
        "sites": len({r["site"] for r in rows}),
        "positives": sum(1 for r in labelled if r["truth"] == positive),
        "tp": tp, "fp": fp, "fn": fn, "fn_residue": fn_residue,
        "precision": (tp / prec_n) if prec_n else float("nan"),
        "precision_ci": wilson(tp, prec_n),
        "precision_n": prec_n,
        # Recall counts the abstentions as misses, because they are: an item
        # you failed to label is an item you failed to find.
        "recall": (tp / (rec_n + fn_residue)) if (rec_n + fn_residue) else float("nan"),
        "recall_ci": wilson(tp, rec_n + fn_residue),
        "recall_n": rec_n + fn_residue,
    }
 
 
def fmt(s, title):
    ci = lambda t: f"[{t[0]:.3f}, {t[1]:.3f}]"
    return "\n".join([
        f"--- {title}",
        f"  items {s['items']}  sites {s['sites']}  hand-labelled {s['labelled']}"
        f"  not hand-labelled {s['unlabelled']}",
        f"  positives in the labelled set {s['positives']}"
        f"  ({100 * s['positives'] / s['labelled']:.1f}%)" if s["labelled"] else "",
        f"  RESIDUE (no usable prediction) {s['residue']}"
        f"  -- of which truly positive {s['fn_residue']}",
        f"  precision {s['precision']:.3f}  95% CI {ci(s['precision_ci'])}  on n={s['precision_n']}",
        f"  recall    {s['recall']:.3f}  95% CI {ci(s['recall_ci'])}  on n={s['recall_n']}",
    ])
 
 
# ---------------------------------------------------------------- demo
def make_population(n_sites=120, pages_per_site=20, seed=1):
    """Sites with a fingerprint, pages that inherit it, and a per-page label.
 
    The label is mostly a property of the SITE (one CMS, one tag manager, one
    consent vendor decided it once) and partly of the page. The features a
    classifier gets are the site fingerprint -- which is memorisable and tells
    it nothing transferable -- and one weak page-level signal that does
    generalise. That is the shape of a real web-measurement label, and it is
    why the two splits disagree.
    """
    rng = random.Random(seed)
    rows = []
    for s in range(n_sites):
        fingerprint = [rng.gauss(0, 1) for _ in range(4)]  # memorisable, useless off-site
        site_offset = rng.gauss(0, 1)  # the part of the label the site decides
        for p in range(pages_per_site):
            signal = rng.gauss(0, 1)  # the part that generalises
            truth = "yes" if site_offset + 0.6 * signal > 0 else "no"
            rows.append({
                "item": f"s{s}p{p}",
                "site": f"site{s}",
                "features": [f + rng.gauss(0, 0.05) for f in fingerprint] + [signal],
                "truth": truth,
            })
    return rows
 
 
def nearest_neighbour_classifier(train, test):
    """1-NN in feature space: the simplest classifier that can memorise.
 
    Not a straw man. A bag-of-words model over page HTML, a URL-pattern
    heuristic, and a few-shot LLM prompt with retrieved examples are all
    memorisers with extra steps, and they all break the same way.
    """
    out = []
    for r in test:
        best, best_d = None, float("inf")
        for t in train:
            d = sum((a - b) ** 2 for a, b in zip(r["features"], t["features"]))
            if d < best_d:
                best, best_d = t, d
        out.append({**r, "prediction": best["truth"] if best else "none"})
    return out
 
 
def demo(seed):
    rows = make_population(seed=seed)
    n_sites = len({r["site"] for r in rows})
    base = sum(1 for r in rows if r["truth"] == "yes") / len(rows)
    print(f"synthetic population: {len(rows)} pages over {n_sites} sites, seed {seed}")
    print(f"positive rate in the whole population: {base:.3f}\n")
    results = {}
    for name, splitter in (("page-level random split (what most papers do)", split_by_item),
                           ("site-grouped split (what the unit of analysis requires)", split_by_site)):
        train, test = splitter(rows, 0.3, seed)
        shared = len({r["site"] for r in train} & {r["site"] for r in test})
        scored = score(nearest_neighbour_classifier(train, test), "yes")
        results[name] = scored
        print(fmt(scored, name))
        print(f"  sites appearing on BOTH sides of the split: {shared}\n")
    print("--- what a rare positive class does to the interval, before anything goes wrong")
    print("    a 1,000-item validation sample at a 3.4% positive rate holds ~34 positives;")
    print("    precision computed on those 34 predicted-positive items:")
    for k in (34, 30, 25):
        lo, hi = wilson(k, 34)
        print(f"      {k}/34 correct -> precision {k / 34:.3f}  95% CI [{lo:.3f}, {hi:.3f}]"
              f"  (width {100 * (hi - lo):.0f} points)")
    print("    A bare point estimate from a sample this size is not a measurement.\n")
 
    a, b = list(results.values())
    print(f"Precision falls from {a['precision']:.3f} to {b['precision']:.3f} and the intervals do not\n"
          f"overlap. That gap is the optimism a page-level split buys. It is not a\n"
          f"property of this toy: it appears whenever the unit you split on is smaller\n"
          f"than the unit your features are constant over -- which, on a crawl, it\n"
          f"almost always is.")
 
 
# ---------------------------------------------------------------- selftest
def selftest():
    """Verify the arithmetic against its own definition, not against a library.
 
    The Wilson endpoints are exactly the two roots of |p_hat - p| = z*sqrt(p(1-p)/n),
    so each endpoint can be checked by substitution. That is a stronger test
    than comparing to a hard-coded number, and it fails if the formula is
    mistyped in any term.
    """
    ok = True
    for k, n in [(8, 10), (41, 175), (1, 100), (26, 175), (3, 3), (0, 50)]:
        lo, hi = wilson(k, n)
        p_hat = k / n
        for end in (lo, hi):
            if end in (0.0, 1.0):
                continue  # clamped; the identity does not apply
            lhs = abs(p_hat - end)
            rhs = Z95 * math.sqrt(end * (1 - end) / n)
            if abs(lhs - rhs) > 1e-9:
                print(f"FAIL wilson({k},{n}) endpoint {end}: {lhs} != {rhs}")
                ok = False
    lo, hi = wilson(0, 50)
    if not (lo == 0.0 and 0.05 < hi < 0.09):
        print(f"FAIL wilson(0,50) = {(lo, hi)}; a zero count must not give a zero-width interval")
        ok = False
 
    # An abstention must not be scored as a negative prediction.
    rows = [
        {"item": "a", "site": "s1", "truth": "yes", "prediction": "yes"},
        {"item": "b", "site": "s1", "truth": "yes", "prediction": "none"},
        {"item": "c", "site": "s2", "truth": "no", "prediction": "no"},
    ]
    s = score(rows, "yes")
    if not (s["residue"] == 1 and s["fn_residue"] == 1 and s["precision"] == 1.0 and abs(s["recall"] - 0.5) < 1e-12):
        print(f"FAIL abstention handling: {s}")
        ok = False
 
    # The grouped split must never put a site on both sides.
    pop = make_population(n_sites=50, pages_per_site=10, seed=7)
    tr, te = split_by_site(pop, 0.3, 7)
    if {r["site"] for r in tr} & {r["site"] for r in te}:
        print("FAIL split_by_site leaked a site across the split")
        ok = False
    if not tr or not te:
        print("FAIL split_by_site produced an empty side")
        ok = False
 
    print("selftest: PASS" if ok else "selftest: FAIL")
    return 0 if ok else 1
 
 
def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = ap.add_subparsers(dest="cmd", required=True)
    sc = sub.add_parser("score")
    sc.add_argument("labels")
    sc.add_argument("--positive", required=True)
    sc.add_argument("--seed", type=int, default=0)
    sc.add_argument("--test-fraction", type=float, default=0.3)
    d = sub.add_parser("demo")
    d.add_argument("--seed", type=int, default=20260911)
    sub.add_parser("selftest")
    a = ap.parse_args()
 
    if a.cmd == "selftest":
        return selftest()
    if a.cmd == "demo":
        demo(a.seed)
        return 0
 
    with open(a.labels, newline="", encoding="utf-8") as fh:
        rows = list(csv.DictReader(fh))
    for need in ("item", "site", "truth", "prediction"):
        if rows and need not in rows[0]:
            sys.exit(f"{a.labels}: missing required column {need!r}")
    print(fmt(score(rows, a.positive), f"all labelled items ({a.labels})"))
    train, test = split_by_site(rows, a.test_fraction, a.seed)
    print()
    print(fmt(score(test, a.positive), f"site-grouped held-out {a.test_fraction:.0%} (train {len(train)})"))
    return 0
 
 
if __name__ == "__main__":
    sys.exit(main())

Its demo subcommand builds a synthetic population with the structure described above — sites with a memorisable fingerprint, a label that is mostly a property of the site — and runs the same 1-NN classifier under both splits. Real, unedited output:

holdout_by_site-demo.txt
synthetic population: 2400 pages over 120 sites, seed 20260911
positive rate in the whole population: 0.510
 
--- page-level random split (what most papers do)
  items 720  sites 120  hand-labelled 720  not hand-labelled 0
  positives in the labelled set 371  (51.5%)
  RESIDUE (no usable prediction) 0  -- of which truly positive 0
  precision 0.964  95% CI [0.940, 0.979]  on n=365
  recall    0.949  95% CI [0.921, 0.967]  on n=371
  sites appearing on BOTH sides of the split: 120
 
--- site-grouped split (what the unit of analysis requires)
  items 840  sites 42  hand-labelled 840  not hand-labelled 0
  positives in the labelled set 497  (59.2%)
  RESIDUE (no usable prediction) 0  -- of which truly positive 0
  precision 0.659  95% CI [0.604, 0.710]  on n=308
  recall    0.408  95% CI [0.366, 0.452]  on n=497
  sites appearing on BOTH sides of the split: 0
 
--- what a rare positive class does to the interval, before anything goes wrong
    a 1,000-item validation sample at a 3.4% positive rate holds ~34 positives;
    precision computed on those 34 predicted-positive items:
      34/34 correct -> precision 1.000  95% CI [0.898, 1.000]  (width 10 points)
      30/34 correct -> precision 0.882  95% CI [0.734, 0.953]  (width 22 points)
      25/34 correct -> precision 0.735  95% CI [0.569, 0.854]  (width 29 points)
    A bare point estimate from a sample this size is not a measurement.
 
Precision falls from 0.964 to 0.659 and the intervals do not
overlap. That gap is the optimism a page-level split buys. It is not a
property of this toy: it appears whenever the unit you split on is smaller
than the unit your features are constant over -- which, on a crawl, it
almost always is.

selftest checks the Wilson endpoints against their own defining equation (each endpoint must satisfy |p̂ − p| = z·√(p(1−p)/n)), rather than against a hard-coded number, so a mistyped term in any position fails. It also checks that an abstention is not scored as a negative and that the grouped split never leaks a group. There is no numpy, scipy or sklearn: it runs where your data is.

When the Annotator Is a Language Model

This is the part that changed after 2023, and the part where the corpus is thinnest. Read the direction as solid and the levels as provisional.

An LLM annotator is a classifier, and it inherits every obligation of one. A held-out set drawn before you look at the output. A human-agreement figure with its coefficient named. The model version and the date you ran it — a model id is not a version. The prompt, published. The decoding settings. A residue count. None of that is new; what is new is how many papers reach for a model without noticing that they have just added a classifier to their pipeline.

And three papers in ten that classify anything validate none of it — 29.9% of 4,439, with 42.4% carrying at least one unvalidated classifier. An LLM does not get a lighter burden because it is fluent.

What the corpus shows

177 papers carry a classification tuple with method == “llm”; 175 of them used or produced the labels rather than only comparing against an LLM baseline. The 175 is the population for everything below.

Year Papers using an LLM to label Share of that year's classifying papers
2023 2 0.4%
2024 27 5.1%
2025 (provisional) 76 12.5%
2026 (provisional) 70 21.0%

2) That page also carries the per-target breakdown, which matters more than the curve: adoption is extremely uneven, and for a cookie or a JavaScript file it has barely started. Do not read “one classifying paper in five” as “there is a baseline for my task”.

Against that, the good news, and it is genuinely good:

Question about the LLM labels Papers Share of 175
The LLM's labels carry some validation 146 83.4%
…manual validation of a sample 110 62.9%
…a held-out test set 16 9.1%
The LLM tuple explicitly reports no validation 35 20.0%
The paper also hand-codes something 151 86.3%
The paper reports an agreement metric 59 33.7%
Neither validation nor any hand coding at all 16 9.1%

83.4% against a 70.1% base rate, and 33.7% reporting an agreement metric against 15.4% of the 3,318 papers that hand-code anything. The likeliest explanation is structural rather than cultural: when a model does the first pass, the human coding is the validation, so a paper without a number has nothing to report. When the Second Rater Is a Language Model reaches the same conclusion from the annotation side with a different matcher, and owns the question of which coefficient that number should be.

What are the labels checked against? 138 of the 175 (78.9%) name a ground truth on an LLM tuple, and 115 of those 138 (83.3%) name human annotation of a sample — a far higher share than the 52.0% corpus-wide, and the reason the method has not yet gone wrong at scale here. Two papers' ground truth for an LLM is another model's output.

[7Cory, Thomas; Rieder, Wolf; Krämer, Julia; Raschke, Philip; Herbke, Patrick; Küpper, Axel (2026): "Word-level Annotation of GDPR Transparency Compliance in Privacy Policies using Large Language Models", Proceedings on Privacy Enhancing Technologies 2026(1):509-528. (DOI)] (PoPETs 2026) is the worked example to copy: a hand-built gold set of “200 privacy policies from Android applications”, “equally distributed among the six experts, with two experts randomly assigned to each pol[icy]”, and the LLM pipeline scored against it. [8Xiong, Shenping; Wang, Xutong; Jin, Ze; Liu, Xinyu; Wang, Haoqiang; Chen, Zhen; Tan, Ru; Liu, Qixu (2026): "TGNN: Enhancing Pixel Tracking Detection via LLM-driven Annotation and GAT-powered Structural Representation", in: Proceedings of the ACM Web Conference. (DOI)] (TheWebConf 2026) is the closest thing in the corpus to a web-measurement template: a 20,000-request ground truth, heuristic-labelled and then validated by human experts (“human experts validate the ground truth dataset”), an LLM labelling component scored against it, and EasyList compared against the same set. It is also the page's own worked warning, twice over. The LLM component's F1 of 98.17% is computed “on the ground truth dataset” — the whole 20,000, with no held-out portion for the labelling step at all — while the extraction records that tuple as held-out-test-set. And the 80/20 split the paper does describe (“into training and testing sets with a ratio of 80% and 20%”) belongs to the downstream graph model that consumes those labels; it says nothing about whether requests from one site land on both sides. Reading what a validation enum actually refers to is not optional, and this one is wrong in the source data this page is built on.

How they ran the model, and whether you could tell

The reproducibility half is measured over the same 175, by full-text probe. Two rows are hand-audited because the probes over-fire; the rest are lower bounds and are marked.

Question Papers Share of 175 How measured
States a numeric decoding temperature 41 23.4% 55 candidates read in context; 11 are a different sense of the word
Names another decoding setting (top-p, top-k, max_tokens, penalties) 31 17.7% probe, lower bound
Says in words that a prompt is reproduced somewhere 26 14.9% 31 candidates read in context; 4 are a different sense, 1 declines to publish
Names a model resolvable to an actual artefact 34 19.4% measured on And almost nobody names a model you could resolve

Three quarters of the papers that let a model assign labels do not say at what temperature, and six in seven do not say the prompt is anywhere. Note what the temperature row is not saying: the word “temperature” appears in 55 of the 175, but in eleven of those it is the colour temperature of a smart bulb, a weather API payload, the softmax τ, a simulated-annealing schedule, or a cited paper's title. A probe that had reported 55 would have been 34% too high, which is why the two figures above that carry a percentage were read by hand.

The prompt figure is a lower bound in both directions and should be quoted that way: a prompt printed in an appendix figure with no sentence pointing at it is invisible. One of the 31 candidates matched because it says it is deliberately withholding its prompts — those are attack prompts rather than annotation prompts, so it is not a counter-example to the norm, but it is a reminder that “the prompt is in the appendix” and “the prompt exists” are different claims.

A model id is not a version, and a model id is not always even a model. GPT-4 names at least six distinct snapshots. [8Xiong, Shenping; Wang, Xutong; Jin, Ze; Liu, Xinyu; Wang, Haoqiang; Chen, Zhen; Tan, Ru; Liu, Qixu (2026): "TGNN: Enhancing Pixel Tracking Detection via LLM-driven Annotation and GAT-powered Structural Representation", in: Proceedings of the ACM Web Conference. (DOI)] says “The specific model of the large language model used in this paper is Qwen3-Plus” — more than most papers give, and it still does not resolve: Alibaba Cloud's Model Studio catalogue lists a qwen3.7-plus and no qwen3-plus, and there is no such checkpoint under Qwen's own account on Hugging Face.3) A hosted tier name ages out; a checkpoint does not. For a hosted model, give the dated snapshot and the date you ran it, because the snapshot will be withdrawn. For an open-weight model, give family, size, instruct-variant and ideally the weights digest. And almost nobody names a model you could resolve measures this on the same 175 and finds 19.4% naming something resolvable.

And do not let your human coders see the model's labels first. Agreement between a human who has read the model's answer and the model is not a measurement of anything. Interrater agreement has the protocol and the coefficients.

Worth stating, because it is the mistake this page was nearly built on. The roadmap row that queued this page came from a title-and-summary probe for LLM annotation, matching 30 papers. All 30 were read:

What the paper actually does with the model Papers Share of 30
annotator — it assigns labels to items in the paper's own study population 21 70.0%
object — the model, or an attack on it, is what is being studied 7 23.3%
instrument — it generates artefacts (fuzzing seeds, interface specs), not labels 2 6.7%

Precision 70%. Recall is 13.1%: the probe catches 23 of the 175 papers the classification.method enum finds. A title probe is a candidate set — good for deciding whether a page is worth writing, useless as a denominator. Every figure on this page comes from the enum. The per-paper verdicts with their reasons are in scripts/annotation_audit.mjs and on the provenance page.

What to Report

A checklist, derived from what the corpus leaves out. Everything here is one or two clauses in a methods section.

  1. What assigned the labels, and what version of it. A filter list has a commit; a service has a query date; a model has a dated snapshot or a checkpoint. “EasyList” and “GPT-4” are not answers.
  2. What you validated against, and why it is independent. One sentence naming the ground truth and one saying what it would take for the ground truth and the classifier to be wrong the same way. 72.9% of classifying papers name a source; the second sentence is almost never there.
  3. How big the validation sample was, how it was drawn, and how it was stratified. If the positive class is rare, stratify and say so, or your precision interval will be wider than your effect.
  4. Precision and recall per class, with an interval — not one global accuracy figure. A macro-average over a class you have 12 examples of is not informative.
  5. The grouping unit of your split, explicitly: by page, by site, by publisher, by time. If you split at random, say that you did, and say what the sites' internal correlation might be worth. Around 2% of the web papers that use a held-out set say anything about this at all.
  6. The residue. How many items got no label, or a label outside your taxonomy, and what you did with them. As a count and as a share of the population before filtering.
  7. The categories you dropped and the rule you dropped them by. A category that failed a precision floor is a finding; a category quietly deleted is not.
  8. If a human coded any of it, everything on What to Report — who, how many, how much was double-coded, which coefficient, resolved how.
  9. If a model did the first pass: the model version and date, the prompt verbatim as an appendix or artifact, the decoding settings, the run-to-run self-agreement, the residue of unparseable answers, and a human–human coefficient on a subsample coded blind to the model's output. Six items; the corpus median is roughly one of them.
  10. Release the labels. The annotated sample is small, it is the only part of this a reader can check, and it is the input to somebody else's meta-analysis. See Artifacts.

Methodology and Limitations of These Figures

  • Two populations, never mixed. CLASSIFIED is the 4,439 papers with at least one classification[] record; ANNOTATED is the 3,318 with at least one humanAnnotation[] record; 2,927 papers are in both. Every figure names which. Never “of 5,859 papers”.
  • Papers, never tuples. A paper with three classifiers using EasyList counts once per row it qualifies for.
  • Sentinels are not answers. not-applicable is reported as its own row and is never folded into “validated”. none-reported is treated as a real value — it is the paper saying there was no check — and that choice is the difference between the 42.4% and the 29.9% above.
  • The enums are publishable; the free text is not. validation and target are enums. method is an enum that agrees about 58% run-to-run, so it is a ranking. groundTruthSource and taxonomy are free text and are only ever reported as a folded family with the residue printed.
  • The ground-truth fold leaves 34.2% of tuples unmapped, touching 1,577 papers, and the residue is printed in full on the provenance page. It is dominated by one-off experimental setups, so known by construction is undercounted. The fold has 21 self-tests including the ordering cases that make the rule order load-bearing.
  • Three of the page's counts are hand audits, not probes — the 30 roadmap candidates, the 55 temperature candidates and the 31 prompt candidates, each with a per-paper verdict and deciding phrase in scripts/annotation_audit.mjs. The script fails loudly if a probe returns a paper the map does not cover, so the audits cannot go stale silently.
  • The grouped-split figure is a lower bound from a probe and is the weakest number here. It supports “rare and almost never stated” and not a count.
  • Quotes were checked in bulk. All 255 evidence quotes on the LLM classification tuples of those 177 papers (255 tuples, not 255 papers) were re-located in the rendering the extractor read: 125 exact, 77 partial, 35 recovered from a second rendering of the PDF, 18 below threshold in both. The 18 were read by hand; all are elisions, math notation or paraphrase-with-ellipsis, none is a fabrication, and the two behind papers named on this page were confirmed in the source. Full output on the provenance page.
  • Venue coverage. Seven venues — CCS, IMC, NDSS, PoPETs, USENIX Security, TheWebConf, IEEE S&P. SOUPS, CHI, CSCW, EuroS&P, ACSAC, RAID and AsiaCCS are absent, and the first three matter here: content analysis is native there and its reporting standards are older. Every figure is a claim about the seven.
  • 2025 and 2026 are provisional. CCS 2026 and IMC 2026 have not been held; IEEE S&P 2026 and TheWebConf 2026 are under-selected because selection screens on abstracts that are not yet indexed. Any row reaching 2026 is a share of a thin, differently-composed slice.
  • The report script is scripts/report_annotation.mjs; every figure above is in its unedited output. The full query log, the fold's residue, the three hand-audit maps, the quote check and what could not be established are on annotation. Corpus-level caveats are on Corpus.

Open Questions

  • Does splitting by site actually change the published numbers, and by how much? The synthetic demo above shows the mechanism; nobody has re-run a published web classifier under a site-grouped split and reported the delta. Every ingredient is public for at least four corpus papers that released both labels and code. This is a weekend's work and it would recalibrate how every F1 in this literature is read.
  • Is the not-applicable sentinel really “no validation reported”? 462 papers are not-applicable on every tuple, which is 10.4% of the population and the difference between two defensible headline figures. Nobody has read a sample of them to find out how many genuinely did not need a check. A hand audit of 50 would settle it.
  • Why do borrowed classifiers go unvalidated three times as often as built ones? The pattern (27.7% vs 84.8%) is clean enough to be a norm rather than an oversight, and the norm may even be defensible for a well-characterised list. Whether it is depends on how far a filter list's precision travels across populations, which Filter Lists can measure and has not.
  • Does LLM annotation quality hold up on web-measurement targets specifically? The 175 are overwhelmingly text tasks — posts, policies, reviews, code. On the targets this site is about (a cookie, a request, a script) the counts are 1, 1 and 0. Whether the method transfers to a semi-structured artefact with no natural language in it is untested here and worth testing before the first paper assumes it does.
  • Nobody reports the residue. No field in the extraction captures “how many items the classifier could not label”, and a full-text probe for it over the 175 returns mostly discussions of parsing rather than counts. Whether that is because it is rare, because it is embarrassing, or because nobody thinks of it as a number is unresolved — and it is the cheapest missing statistic on this page.
  • Interrater agreement — the sibling. It owns the coefficients, the rare-class kappa paradox, and what to report when people coded the data. This page owns the validation design; neither repeats the other.
  • Website classification — the largest single labelling task on this site, its own validation protocol and category_validation.py, and the per-target LLM adoption table all three classification pages read from.
  • Cookies, Requests, IP classification, Mobile and app measurement — the target pages. Their validation rates are in the table above, and three of the four are below the base rate.
  • Filter Lists — the borrowed classifier this field trusts most and validates least.
  • Hypothesis testing — the unit-of-analysis problem again, one step downstream: the same site correlation that breaks a random split breaks an independence assumption.
  • Biases — an unreliable label set is a measurement-error problem before it is anything else.
  • Study preregistration — where “we set the precision floor in advance” becomes checkable rather than assertable.
  • Artifacts — release the labels, the codebook and the prompt.

References

[1]
Arp, Daniel; Quiring, Erwin; Pendlebury, Feargus; Warnecke, Alexander; Pierazzi, Fabio; Wressnegger, Christian; Cavallaro, Lorenzo; Rieck, Konrad (2022): "Dos and Don'ts of Machine Learning in Computer Security", in: Proceedings of the USENIX Security Symposium. (Link)
[2]
Pendlebury, Feargus; Pierazzi, Fabio; Jordaney, Roberto; Kinder, Johannes; Cavallaro, Lorenzo (2019): "TESSERACT: Eliminating Experimental Bias in Malware Classification across Space and Time", in: Proceedings of the USENIX Security Symposium. (Link)
[3]
Ruth, Kimberly; Fass, Aurore; Azose, Jonathan; Pearson, Mark; Thomas, Emma; Sadowski, Caitlin; Durumeric, Zakir (2022): "A World Wide View of Browsing the World Wide Web", in: Proceedings of the ACM Internet Measurement Conference, pp. 317-336. (DOI)
[4]
Munir, Shaoor; Siby, Sandra; Iqbal, Umar; Englehardt, Steven; Shafiq, Zubair; Troncoso, Carmela (2023): "CookieGraph: Understanding and Detecting First-Party Tracking Cookies", pp. 3490–3504. Association for Computing Machinery, New York, NY, USA. (DOI) (Link)
[5]
Pangakis, Nicholas; Wolken, Samuel; Fasching, Neil (2023): "Automated Annotation with Generative AI Requires Validation". arXiv:2306.00176. (Link)
[6]
Törnberg, Petter (2024): "Best Practices for Text Annotation with Large Language Models", Sociologica 18(2):67-85. (DOI)
[7]
Cory, Thomas; Rieder, Wolf; Krämer, Julia; Raschke, Philip; Herbke, Patrick; Küpper, Axel (2026): "Word-level Annotation of GDPR Transparency Compliance in Privacy Policies using Large Language Models", Proceedings on Privacy Enhancing Technologies 2026(1):509-528. (DOI)
[8]
Xiong, Shenping; Wang, Xutong; Jin, Ze; Liu, Xinyu; Wang, Haoqiang; Chen, Zhen; Tan, Ru; Liu, Qixu (2026): "TGNN: Enhancing Pixel Tracking Detection via LLM-driven Annotation and GAT-powered Structural Representation", in: Proceedings of the ACM Web Conference. (DOI)
1)
data/extract/OVERVIEW.md, per-year table; see Corpus.
2)
Counting all 177 rather than the 175 gives 77 in 2025 and 71 in 2026, which is the series Where LLMs actually appear prints: that page is asking whether the method has arrived at a target, so a comparison-only baseline counts; this page is asking whether the labels were validated, so it does not. Same field, different question, and the two-paper gap is the whole difference.
3)
Checked 2026-09-11 against alibabacloud.com/help/en/model-studio/models and the Hugging Face model API. The hosted Plus tier's id carries the generation number and churns with it, so a paper naming the tier without the generation and the date has named nothing durable.
You could leave a comment if you were logged in.
statistics/annotation.1789094551.txt.gz · Last modified: by karel.kubicek.claude