polaudit 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
polaudit/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """A-posteriori unimodality (AU) of annotator subgroups, and the language models used to audit it."""
2
+ from .agreement import cohen_kappa, spearman, weighted_kappa
3
+ from .annotation import (AnnotationError, annotate, collect_runs, ensemble, inspect_run, load_model_inputs, progress,
4
+ sample_inputs)
5
+ from .datasets import SPECS, get_spec
6
+ from .eda import eda_figures, eda_tables, save_eda
7
+ from .eda_dataset import dataset_tables, eda_overview_all, save_dataset_eda
8
+ from .attribution_bootstrap import attribution_gap, attribution_table, bootstrap_gap
9
+ from .kappa_groups import compute_kappa, save_kappa
10
+ from .prompt_sensitivity import compare_prompts
11
+ from .persona_kappa import compute_persona_kappa, save_persona_kappa
12
+ from .shuffling_robustness import compute_null, save_null
13
+ from .poles import compute_poles, save_poles
14
+ from .model_comparison import compare_models
15
+ from .model_eda import label_share_figure, model_eda, save_model_eda, score_histogram_figure
16
+ from .env import Env
17
+ from .export import csv_ready, sample_per_dimension, save_au_tables
18
+ from .models import DEFAULT_CONFIGS, Model, ModelConfig, ModelError, ModelHub
19
+ from .persona_annotation import (annotate_personas, collect_persona_runs, group_order, inspect_personas, load_persona_inputs,
20
+ persona_long)
21
+ from .pipeline import DatasetResult, clean, load_results, make_tool, run_dataset
22
+ from .prompts import PersonaPrompt, ScoringPrompt, label_from_score, save_prompts
23
+ from .sampling import cover_per_dimension, minimal_cover
24
+ from .scoring import score_personas, score_texts
25
+ from .spec import DatasetSpec
26
+ from .unimodality import PrunedAU, Unimodality, dfu_of_hist
27
+ from .utils import find_root, read_au_csv
28
+
29
+ __version__ = "1.0.0"
30
+ __all__ = ["Unimodality", "PrunedAU", "dfu_of_hist",
31
+ "DatasetSpec", "SPECS", "get_spec", "run_dataset", "DatasetResult", "clean", "make_tool",
32
+ "save_au_tables", "csv_ready", "sample_per_dimension", "minimal_cover", "cover_per_dimension", "find_root", "read_au_csv",
33
+ "Env", "Model", "ModelConfig", "ModelHub", "ModelError", "DEFAULT_CONFIGS",
34
+ "ScoringPrompt", "PersonaPrompt", "label_from_score", "score_texts", "score_personas",
35
+ "annotate", "ensemble", "load_model_inputs", "progress", "inspect_run", "collect_runs", "sample_inputs",
36
+ "AnnotationError", "cohen_kappa", "weighted_kappa", "spearman",
37
+ "eda_tables", "eda_figures", "save_eda", "dataset_tables", "save_dataset_eda", "eda_overview_all",
38
+ "attribution_gap", "attribution_table", "bootstrap_gap", "load_results", "compute_kappa", "save_kappa", "compute_persona_kappa", "save_persona_kappa", "save_prompts",
39
+ "compute_null", "compare_prompts", "compare_models", "model_eda", "save_model_eda", "score_histogram_figure", "label_share_figure", "save_null", "compute_poles", "save_poles", "annotate_personas", "load_persona_inputs", "persona_long", "collect_persona_runs", "inspect_personas", "group_order"]
polaudit/agreement.py ADDED
@@ -0,0 +1,61 @@
1
+ """How much two sets of ratings of the same texts agree: two prompts, two models, a model and a group of people.
2
+
3
+ ``cohen_kappa`` is for labels (civil / toxic), ``weighted_kappa`` and ``spearman`` for scores. For all of them 1 means perfect
4
+ agreement, 0 means no more agreement than chance would give, and a negative value means less. A pair where one side is missing
5
+ (NaN) is left out. A value that cannot be computed (everything rated the same by both sides, no pairs left) is NaN.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Iterable, Sequence
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+
14
+ __all__ = ["cohen_kappa", "weighted_kappa", "spearman"]
15
+
16
+
17
+ def _pairs(a: Iterable, b: Iterable):
18
+ a, b = np.asarray(list(a), dtype=object), np.asarray(list(b), dtype=object)
19
+ if len(a) != len(b):
20
+ raise ValueError(f"the two sets of ratings must have the same length ({len(a)} and {len(b)})")
21
+ keep = ~(pd.isna(a) | pd.isna(b))
22
+ return a[keep], b[keep]
23
+
24
+
25
+ def cohen_kappa(a: Iterable, b: Iterable) -> float:
26
+ """Cohen's kappa of two sets of labels (any values that can be compared, e.g. ``"toxic"``/``"civil"`` or True/False)."""
27
+ a, b = _pairs(a, b)
28
+ if len(a) == 0:
29
+ return float("nan")
30
+ chance = sum(float(np.mean(a == c)) * float(np.mean(b == c)) for c in set(a) | set(b))
31
+ return float("nan") if chance == 1 else (float(np.mean(a == b)) - chance) / (1 - chance)
32
+
33
+
34
+ def weighted_kappa(a: Iterable, b: Iterable, scale: Sequence[int] = range(1, 6)) -> float:
35
+ """Quadratic weighted kappa of two sets of scores on ``scale`` (default 1-5): a disagreement of 2 points counts four times as much as
36
+ one of 1 point. A score outside ``scale`` raises ``ValueError``."""
37
+ a, b = _pairs(a, b)
38
+ scale = list(scale)
39
+ if len(a) == 0 or len(scale) < 2:
40
+ return float("nan")
41
+ position = {int(v): i for i, v in enumerate(scale)}
42
+ try:
43
+ seen = np.zeros((len(scale), len(scale)))
44
+ for x, y in zip(a, b):
45
+ seen[position[int(x)], position[int(y)]] += 1
46
+ except KeyError as missing:
47
+ raise ValueError(f"score {missing} is not on the scale {scale[0]}-{scale[-1]}") from None
48
+ expected = np.outer(seen.sum(axis=1), seen.sum(axis=0)) / seen.sum()
49
+ steps = np.arange(len(scale))
50
+ weight = (steps[:, None] - steps[None, :]) ** 2 / (len(scale) - 1) ** 2
51
+ spread = float((weight * expected).sum())
52
+ return float("nan") if spread == 0 else 1 - float((weight * seen).sum()) / spread
53
+
54
+
55
+ def spearman(a: Iterable, b: Iterable) -> float:
56
+ """Spearman rank correlation of two sets of scores: do they put the texts in the same order?"""
57
+ a, b = _pairs(a, b)
58
+ if len(a) < 2:
59
+ return float("nan")
60
+ ra, rb = pd.Series(a.astype(float)).rank(), pd.Series(b.astype(float)).rank()
61
+ return float("nan") if ra.nunique() < 2 or rb.nunique() < 2 else float(ra.corr(rb)) # one side never changes: undefined
polaudit/annotation.py ADDED
@@ -0,0 +1,218 @@
1
+ """Run ONE model over the texts of the datasets and save every reply (the baseline: no persona).
2
+
3
+ ``annotate(model, inputs, out_dir, datasets=[...])`` is called once per model (and, if wanted, once per dataset), from the
4
+ notebook, so the order is controlled there and a problem shows up where it happens. Everything of a dataset is kept in its
5
+ own folder, ``out_dir/<dataset>/<run>/``::
6
+
7
+ <model>.csv one row per text: dataset, item_id, text, model, run, batch, score, label (one file per model)
8
+ all_models.csv the models side by side, one row per text: <model>_score, <model>_label for every model run so far
9
+ raw/<model>.raw.jsonl one line per batch: the prompt, the reply, the parsed scores (the source of truth)
10
+
11
+ and one line per (model, dataset) pass is added to ``out_dir/run_log.csv``. ``all_models.csv`` is refreshed after every pass.
12
+ If a run stops (an API error, a reply that cannot be parsed) everything finished so far is kept, and calling ``annotate``
13
+ again carries on at the first missing batch.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import time
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+ from typing import Callable, Dict, Iterable, List, Optional
22
+
23
+ import pandas as pd
24
+ from tqdm.auto import tqdm
25
+
26
+ from .datasets import SPECS
27
+ from .models import DEFAULT_CONFIGS, Model
28
+ from .prompts import ScoringPrompt
29
+
30
+ __all__ = ["annotate", "ensemble", "load_model_inputs", "progress", "inspect_run", "collect_runs", "sample_inputs", "AnnotationError"]
31
+
32
+ ENSEMBLE_FILE = "all_models.csv"
33
+ MODEL_ORDER = list(DEFAULT_CONFIGS) # the order of the models in all_models.csv; other names follow alphabetically
34
+
35
+ LOG_COLUMNS = ["time_utc", "run", "dataset", "model", "provider", "model_id", "temperature", "max_tokens", "prompt_version",
36
+ "item_word", "batch_size", "texts", "batches", "batches_resumed", "model_calls", "seconds", "status"]
37
+
38
+
39
+ class AnnotationError(RuntimeError):
40
+ """A batch could not be annotated; what was finished before it is saved."""
41
+
42
+
43
+ def load_model_inputs(path: Path) -> Dict[str, pd.DataFrame]:
44
+ """The texts to annotate, per dataset: each text once, in the order of the file (``item_id``, ``text``)."""
45
+ table = pd.read_csv(path, dtype={"item_id": str})
46
+ return {dataset: part.drop_duplicates("item_id")[["item_id", "text"]].reset_index(drop=True)
47
+ for dataset, part in table.groupby("dataset", sort=False)}
48
+
49
+
50
+ def _paths(out_dir: Path, dataset: str, run: str, model: str):
51
+ folder = Path(out_dir) / dataset / run
52
+ return folder, folder / "raw" / f"{model}.raw.jsonl", folder / f"{model}.csv"
53
+
54
+
55
+ def ensemble(out_dir: Path, dataset: str, run: str = "baseline", models: Optional[Iterable[str]] = None) -> pd.DataFrame:
56
+ """The models of one dataset side by side: one row per text, ``<model>_score`` and ``<model>_label`` for each model that has
57
+ a saved file (or for ``models``). Written to ``<dataset>/<run>/all_models.csv`` and returned."""
58
+ folder = Path(out_dir) / dataset / run
59
+ found = [p.stem for p in folder.glob("*.csv") if p.name != ENSEMBLE_FILE]
60
+ names = list(models) if models is not None else sorted(found, key=lambda m: (MODEL_ORDER.index(m) if m in MODEL_ORDER else len(MODEL_ORDER), m))
61
+ merged = None
62
+ for name in names:
63
+ part = pd.read_csv(folder / f"{name}.csv", dtype={"item_id": str})
64
+ part = part[["dataset", "item_id", "text", "score", "label"]].rename(columns={"score": f"{name}_score", "label": f"{name}_label"})
65
+ merged = part if merged is None else merged.merge(part.drop(columns=["dataset", "text"]), on="item_id", how="outer")
66
+ if merged is None:
67
+ return pd.DataFrame()
68
+ merged.to_csv(folder / ENSEMBLE_FILE, index=False, encoding="utf-8")
69
+ return merged
70
+
71
+
72
+ def _read_batches(raw_path: Path) -> Dict[int, dict]:
73
+ done: Dict[int, dict] = {}
74
+ if raw_path.exists():
75
+ for line in raw_path.read_text(encoding="utf-8").splitlines():
76
+ record = json.loads(line)
77
+ if record["status"] == "ok":
78
+ done[record["batch"]] = record
79
+ return done
80
+
81
+
82
+ def _write_csv(dataset: str, run: str, model: str, done: Dict[int, dict], csv_path: Path) -> None:
83
+ """The csv is rebuilt from the saved batches only (each keeps its own texts), never from the current input."""
84
+ rows = [{"dataset": dataset, "item_id": item_id, "text": text, "model": model, "run": run,
85
+ "score": parsed["score"], "label": parsed["label"]}
86
+ for _, record in sorted(done.items())
87
+ for item_id, text, parsed in zip(record["item_ids"], record["texts"], record["parsed"])]
88
+ pd.DataFrame(rows, columns=["dataset", "item_id", "text", "model", "run", "score", "label"]).to_csv(csv_path, index=False, encoding="utf-8")
89
+
90
+
91
+ def _log(out_dir: Path, **fields) -> None:
92
+ path = Path(out_dir) / "run_log.csv"
93
+ line = pd.DataFrame([{c: fields.get(c) for c in LOG_COLUMNS}], columns=LOG_COLUMNS)
94
+ line.to_csv(path, mode="a", header=not path.exists(), index=False, encoding="utf-8")
95
+
96
+
97
+ def annotate(model: Model, inputs: Dict[str, pd.DataFrame], out_dir: Path, prompt: Callable[[str, str], str], run: str = "baseline",
98
+ batch_size: int = 10, parse_retries: int = 2, limit: Optional[int] = None, datasets: Optional[Iterable[str]] = None,
99
+ item_words: Optional[Dict[str, str]] = None, scale_max: int = 5, toxic_threshold: float = 3,
100
+ show_progress: bool = True) -> pd.DataFrame:
101
+ """Score (1-5) and label every text of every dataset with one model, batch by batch. Returns one row per dataset.
102
+
103
+ ``limit`` keeps only the first texts of each dataset (a pilot). ``item_words`` overrides the word used for a text in the
104
+ prompt (default: the word of the dataset, e.g. "comment"). A batch whose reply does not have exactly one score per text is
105
+ asked again ``parse_retries`` times and then raises ``AnnotationError`` (the reply is kept in the raw file).
106
+ ``prompt`` is your function ``prompt(texts, item) -> str``: ``texts`` is the batch already numbered ("1. ...\n2. ..."), ``item``
107
+ the word for a text, and the string it returns is sent to the model. The reply must have a line ``score=<n>, label=<toxic/civil>``
108
+ per text. Use another ``run`` name when you change the prompt. ``show_progress`` shows a bar per dataset and model (texts done,
109
+ time elapsed and left, and the batch being asked).
110
+ """
111
+ if not callable(prompt):
112
+ raise TypeError("prompt must be a function prompt(texts, item) -> str")
113
+ words = {name: spec.item_word for name, spec in SPECS.items()}
114
+ words.update(item_words or {})
115
+ summary = []
116
+ for dataset in (list(datasets) if datasets is not None else list(inputs)):
117
+ table = inputs[dataset].head(limit) if limit else inputs[dataset]
118
+ table = table.reset_index(drop=True)
119
+ scoring = ScoringPrompt(item=words.get(dataset, "text"), scale_max=scale_max, toxic_threshold=toxic_threshold, write=prompt)
120
+ folder, raw_path, csv_path = _paths(out_dir, dataset, run, model.name)
121
+ raw_path.parent.mkdir(parents=True, exist_ok=True)
122
+ done = _read_batches(raw_path)
123
+ batches = [table.iloc[i:i + batch_size] for i in range(0, len(table), batch_size)]
124
+ started, calls, resumed, status = time.time(), 0, 0, "complete"
125
+ bar = tqdm(total=len(table), desc=f"{dataset} / {model.name}", unit="text", disable=not show_progress)
126
+ try:
127
+ for number, batch in enumerate(batches, start=1):
128
+ ids = batch["item_id"].tolist()
129
+ if number in done:
130
+ if done[number]["item_ids"] != ids:
131
+ raise AnnotationError(f"{model.name}/{dataset}: saved batch {number} holds other texts than the input; use another run name")
132
+ resumed += 1
133
+ bar.update(len(batch))
134
+ continue
135
+ first = str(batch["text"].iloc[0]).replace("\n", " ")
136
+ bar.set_postfix_str(f"batch {number}/{len(batches)}: {first[:40]}...", refresh=True)
137
+ question = scoring.build(batch["text"].tolist())
138
+ for attempt in range(1, parse_retries + 2):
139
+ reply = model.complete(question)
140
+ calls += 1
141
+ parsed = scoring.parse(reply)
142
+ if len(parsed) == len(batch):
143
+ break
144
+ else:
145
+ with open(raw_path, "a", encoding="utf-8") as f:
146
+ f.write(json.dumps({"batch": number, "item_ids": ids, "prompt": question, "reply": reply, "attempts": attempt,
147
+ "status": "unparseable"}, ensure_ascii=False) + "\n")
148
+ raise AnnotationError(f"{model.name}/{dataset}: batch {number}: expected {len(batch)} scores, received {len(parsed)} "
149
+ f"after {attempt} attempts; the reply is in {raw_path.name}")
150
+ record = {"batch": number, "item_ids": ids, "texts": batch["text"].tolist(), "prompt": question, "reply": reply,
151
+ "parsed": parsed, "attempts": attempt,
152
+ "status": "ok", "time_utc": datetime.now(timezone.utc).isoformat(timespec="seconds")}
153
+ with open(raw_path, "a", encoding="utf-8") as f:
154
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
155
+ done[number] = record
156
+ bar.update(len(batch))
157
+ except Exception as exc:
158
+ status = f"failed: {type(exc).__name__}: {exc}"[:300]
159
+ raise
160
+ finally:
161
+ bar.close()
162
+ _write_csv(dataset, run, model.name, done, csv_path)
163
+ try:
164
+ ensemble(out_dir, dataset, run) # all_models.csv follows the model that was just run
165
+ except Exception as exc: # never hide the error that stopped the run
166
+ print(f"warning: all_models.csv of {dataset}/{run} could not be refreshed: {exc!r}")
167
+ cfg = model.config
168
+ _log(out_dir, time_utc=datetime.now(timezone.utc).isoformat(timespec="seconds"), run=run, dataset=dataset, model=model.name,
169
+ provider=cfg.provider, model_id=cfg.model_id, temperature=cfg.temperature, max_tokens=cfg.max_tokens,
170
+ prompt_version=getattr(prompt, "__name__", "custom"), item_word=words.get(dataset, "text"), batch_size=batch_size, texts=len(table),
171
+ batches=len(batches), batches_resumed=resumed, model_calls=calls, seconds=round(time.time() - started, 1), status=status)
172
+ summary.append({"dataset": dataset, "texts": len(table), "batches": len(batches), "resumed": resumed, "model calls": calls,
173
+ "seconds": round(time.time() - started, 1)})
174
+ return pd.DataFrame(summary).set_index("dataset")
175
+
176
+
177
+ def progress(inputs: Dict[str, pd.DataFrame], out_dir: Path, models: Iterable[str], run: str = "baseline") -> pd.DataFrame:
178
+ """For each dataset and model: how many of the texts have a saved score."""
179
+ rows = {}
180
+ for dataset, table in inputs.items():
181
+ row = {"texts": len(table)}
182
+ for name in models:
183
+ _, raw_path, _ = _paths(out_dir, dataset, run, name)
184
+ row[name] = sum(len(r["item_ids"]) for r in _read_batches(raw_path).values())
185
+ rows[dataset] = row
186
+ return pd.DataFrame(rows).T
187
+
188
+
189
+ def inspect_run(out_dir: Path, model: str, run: str = "baseline") -> pd.DataFrame:
190
+ """A first look at what one model returned, per dataset: how many texts, the scores it gave, the share it called toxic, and
191
+ whether any batch had to be asked again or could not be parsed. Meant for checking a model before running the next one."""
192
+ rows = {}
193
+ for csv_path in sorted(Path(out_dir).glob(f"*/{run}/{model}.csv")):
194
+ table = pd.read_csv(csv_path)
195
+ raw_path = csv_path.parent / "raw" / f"{model}.raw.jsonl"
196
+ records = [json.loads(line) for line in raw_path.read_text(encoding="utf-8").splitlines()] if raw_path.exists() else []
197
+ row = {"texts": len(table), "mean score": round(float(table["score"].mean()), 2) if len(table) else None,
198
+ "toxic %": round(100 * float((table["label"] == "toxic").mean())) if len(table) else None}
199
+ row.update({f"score {s}": int((table["score"] == s).sum()) for s in range(1, 6)})
200
+ row["batches asked again"] = sum(1 for r in records if r["status"] == "ok" and r["attempts"] > 1)
201
+ row["batches not parsed"] = sum(1 for r in records if r["status"] == "unparseable")
202
+ rows[csv_path.parent.parent.name] = row
203
+ return pd.DataFrame(rows).T if rows else pd.DataFrame()
204
+
205
+
206
+ def sample_inputs(inputs: Dict[str, pd.DataFrame], n: int, seed: int = 42) -> Dict[str, pd.DataFrame]:
207
+ """``n`` random texts of every dataset (all of them when a dataset has fewer), in the order of the input. The same ``n`` and
208
+ ``seed`` always give the same texts, so every prompt and every model sees the same subset."""
209
+ return {name: table.sample(n=min(n, len(table)), random_state=seed).sort_index().reset_index(drop=True)
210
+ for name, table in inputs.items()}
211
+
212
+
213
+ def collect_runs(out_dir: Path, run: str = "baseline") -> pd.DataFrame:
214
+ """All saved scores of a run: one row per dataset, model and text."""
215
+ files = sorted(p for p in Path(out_dir).glob(f"*/{run}/*.csv") if p.name != ENSEMBLE_FILE)
216
+ if not files:
217
+ return pd.DataFrame(columns=["dataset", "item_id", "text", "model", "run", "score", "label"])
218
+ return pd.concat([pd.read_csv(f, dtype={"item_id": str}) for f in files], ignore_index=True)
@@ -0,0 +1,86 @@
1
+ """Do non-demographic characteristics attribute a larger share of the testable texts than demographic ones?
2
+
3
+ Nothing here asks a model for anything, and nothing changes the AU analysis: it reads the final results after the removal of thin groups.
4
+
5
+ For one dataset the unit is a **polarized text with at least four ratings** (a text needs two groups of two ratings to be compared). For every
6
+ characteristic of the dataset, such a text is
7
+
8
+ * **testable** when at least two groups have two or more ratings on it (after the removal of thin groups), and
9
+ * **attributed** when it is AU for that characteristic.
10
+
11
+ The characteristics gender, race and age are *demographic* (D); every other one (politics or ideology, traditionalism, religion, education, occupation) is
12
+ *non-demographic* (N). The pooled attribution rate of a type is attributed / testable summed over its characteristics, and the gap is ``N - D`` in
13
+ percentage points. The texts are resampled with replacement (``reps`` times, fixed ``seed``); in every resample both rates and the gap are computed again from
14
+ the same drawn texts, so the two types move together. The 95% interval is the 2.5th to 97.5th percentile of the gaps.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Dict, Optional, Sequence
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+
23
+ from .eda import _texts
24
+ from .names import ordered_datasets
25
+ from .pipeline import DatasetResult
26
+
27
+ __all__ = ["attribution_table", "bootstrap_gap", "attribution_gap"]
28
+
29
+ DEMOGRAPHIC = ("gender", "race", "age")
30
+ COLUMNS = ["testable D", "attributed D", "testable N", "attributed N"]
31
+
32
+
33
+ def attribution_table(result: DatasetResult, demographic: Sequence[str] = DEMOGRAPHIC, min_text_ratings: Optional[int] = None) -> pd.DataFrame:
34
+ """One row per polarized text with at least ``min_text_ratings`` ratings (default: ``min_ratings * min_groups`` of the dataset, four with the default rules): in how
35
+ many demographic (D) and non-demographic (N) characteristics it is testable and in how many it is attributed (AU), from the final results after the removal of
36
+ thin groups. ``demographic`` names the characteristics that count as demographic (every other one is non-demographic)."""
37
+ texts = _texts(result)
38
+ if min_text_ratings is None:
39
+ min_text_ratings = result.tool.min_ratings * result.tool.min_groups
40
+ keep = texts.index[texts["polarised"] & (texts["ratings"] >= min_text_ratings)]
41
+ table = pd.DataFrame(0, index=keep, columns=COLUMNS, dtype=int)
42
+ for dimension, pr in result.pruned.items():
43
+ kind = "D" if dimension in demographic else "N"
44
+ rows = pr.result.set_index("item_id")
45
+ testable = (rows["polarised"] & (rows["kept_groups"].map(len) >= result.tool.min_groups)).reindex(keep, fill_value=False)
46
+ attributed = rows["au"].reindex(keep, fill_value=False)
47
+ table[f"testable {kind}"] += testable.to_numpy().astype(int)
48
+ table[f"attributed {kind}"] += attributed.to_numpy().astype(int)
49
+ return table
50
+
51
+
52
+ def _rates(sums: np.ndarray):
53
+ """The pooled rates (D, N) of a vector of the four sums; NaN when a type has nothing testable."""
54
+ with np.errstate(divide="ignore", invalid="ignore"):
55
+ return sums[1] / sums[0], sums[3] / sums[2]
56
+
57
+
58
+ def bootstrap_gap(table: pd.DataFrame, reps: int = 10_000, seed: int = 42) -> dict:
59
+ """The observed gap ``N - D`` of the pooled attribution rates (percentage points), its 95% percentile interval over ``reps`` resamples of the texts, and
60
+ the share of the resamples in which the gap is zero or negative."""
61
+ values = table[COLUMNS].to_numpy(dtype=float)
62
+ n = len(values)
63
+ rate_d, rate_n = _rates(values.sum(axis=0))
64
+ rng = np.random.default_rng(seed)
65
+ gaps = np.empty(reps)
66
+ for b in range(reps):
67
+ counts = np.bincount(rng.integers(0, n, n), minlength=n) # how often each text is drawn
68
+ d, m = _rates(counts @ values)
69
+ gaps[b] = 100 * (m - d)
70
+ finite = gaps[np.isfinite(gaps)]
71
+ return {"texts": n, "testable D": int(values[:, 0].sum()), "attributed D": int(values[:, 1].sum()), "rate D %": 100 * rate_d,
72
+ "testable N": int(values[:, 2].sum()), "attributed N": int(values[:, 3].sum()), "rate N %": 100 * rate_n,
73
+ "N - D (pp)": 100 * (rate_n - rate_d), "95% interval low": float(np.percentile(finite, 2.5)), "95% interval high": float(np.percentile(finite, 97.5)),
74
+ "share of resamples with N - D <= 0 (%)": 100 * float(np.mean(finite <= 0)), "resamples": reps, "resamples without a rate": int(reps - len(finite))}
75
+
76
+
77
+ def attribution_gap(results: Dict[str, DatasetResult], reps: int = 10_000, seed: int = 42, demographic: Sequence[str] = DEMOGRAPHIC,
78
+ min_text_ratings: Optional[int] = None) -> pd.DataFrame:
79
+ """``bootstrap_gap`` for every dataset (in the order Attitudes, POPQUORN, MHS, Kumar). Every dataset starts from the same ``seed``, so its result does
80
+ not depend on the other datasets."""
81
+ rows = {name: bootstrap_gap(attribution_table(results[name], demographic, min_text_ratings), reps, seed) for name in ordered_datasets(results)}
82
+ table = pd.DataFrame.from_dict(rows, orient="index").rename_axis("dataset")
83
+ for column in ("rate D %", "rate N %", "N - D (pp)", "95% interval low", "95% interval high"):
84
+ table[column] = table[column].round(2)
85
+ table["share of resamples with N - D <= 0 (%)"] = table["share of resamples with N - D <= 0 (%)"].round(2)
86
+ return table
@@ -0,0 +1,13 @@
1
+ """The datasets: one module each, with its loader and the definition of its annotator groups."""
2
+ from . import attitudes, kumar, mhs, popquorn
3
+
4
+ SPECS = {spec.name: spec for spec in (popquorn.SPEC, attitudes.SPEC, mhs.SPEC, kumar.SPEC)}
5
+
6
+
7
+ def get_spec(name: str):
8
+ if name not in SPECS:
9
+ raise KeyError(f"unknown dataset {name!r}; known: {sorted(SPECS)}")
10
+ return SPECS[name]
11
+
12
+
13
+ __all__ = ["SPECS", "get_spec", "popquorn", "attitudes", "mhs", "kumar"]
@@ -0,0 +1,69 @@
1
+ """Attitudes: 1-5 ratings of 626 tweets (`toyou`); the annotator attributes are lists parallel to the ratings."""
2
+ from __future__ import annotations
3
+
4
+ import ast
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+ from ..spec import DatasetSpec
13
+ from .groups import age_generations
14
+
15
+ PARALLEL = {"rating": "toyou", "gender": "annotatorGender", "race": "annotatorRace", "age": "annotatorAge",
16
+ "politics": "annotatorPolitics", "traditionalism": "traditionalism"}
17
+
18
+
19
+ def _as_list(x):
20
+ if isinstance(x, str):
21
+ try:
22
+ x = ast.literal_eval(x)
23
+ except Exception:
24
+ return []
25
+ return list(x) if isinstance(x, (list, tuple, np.ndarray)) else []
26
+
27
+
28
+ def load(root: Path, path: Optional[Path] = None) -> pd.DataFrame:
29
+ """One row per rating. ``path`` (or ``POLAUDIT_ATTITUDES_FILE``) overrides the default location.
30
+ NOTE: despite its .csv extension, the file is a pickled DataFrame."""
31
+ path = Path(path or os.environ.get("POLAUDIT_ATTITUDES_FILE") or os.environ.get("POLALIGN_ATTITUDES_FILE") or Path(root) / "Thesis" / "Code" / "attitudes_embedded.csv")
32
+ raw = pd.read_pickle(path)
33
+ lists = {name: raw[col].map(_as_list) for name, col in PARALLEL.items()}
34
+ lengths = pd.DataFrame({name: s.map(len) for name, s in lists.items()})
35
+ if not ((lengths.nunique(axis=1) == 1).all() and (lengths["rating"] > 0).all()):
36
+ raise ValueError("ratings and annotator attributes do not line up in the attitudes file")
37
+ rows = [(idx, raw.at[idx, "tweet"]) + tuple(lists[name].at[idx][k] for name in PARALLEL)
38
+ for idx in raw.index for k in range(lengths.at[idx, "rating"])]
39
+ return pd.DataFrame(rows, columns=["item_id", "text"] + list(PARALLEL))
40
+
41
+
42
+ def _more_than_three_ratings(table: pd.DataFrame) -> pd.DataFrame:
43
+ """Texts with only 3 annotations are removed."""
44
+ return table[table.groupby("item_id")["rating"].transform("size") > 3]
45
+
46
+
47
+ def _politics_group(score: pd.Series) -> pd.Series:
48
+ return pd.cut(pd.to_numeric(score, errors="coerce"), [-10, -0.25, 0.25, 10], labels=["left", "center", "right"]).astype(object)
49
+
50
+
51
+ def _traditionalism_group(score: pd.Series) -> pd.Series:
52
+ return pd.cut(pd.to_numeric(score, errors="coerce"), [0, 3, 5], labels=["not_high", "high"]).astype(object)
53
+
54
+
55
+ SPEC = DatasetSpec(
56
+ name="attitudes",
57
+ load=load,
58
+ dimensions={
59
+ "gender": ("gender", {"man": "man", "woman": "woman"}),
60
+ "race": ("race", {"white": "white", "black": "black"}),
61
+ "age": ("age", age_generations),
62
+ "politics": ("politics", _politics_group),
63
+ "traditionalism": ("traditionalism", _traditionalism_group),
64
+ },
65
+ prefilter=_more_than_three_ratings,
66
+ item_word="tweet",
67
+ description="Attitudes, 1-5 `toyou` ratings",
68
+ rating_column="toyou", # no annotator id in the data
69
+ )
@@ -0,0 +1,13 @@
1
+ """Group definitions shared by several datasets."""
2
+ from __future__ import annotations
3
+
4
+ import pandas as pd
5
+
6
+ __all__ = ["age_generations"]
7
+
8
+
9
+ def age_generations(ages: pd.Series) -> pd.Series:
10
+ """Age in years -> ``gen z`` (under 25), ``millenial`` (25-44), ``gen x+`` (45 and over). Missing stays missing.
11
+ (The spelling ``millenial`` is the one used in all the results.)"""
12
+ return pd.cut(pd.to_numeric(ages, errors="coerce"), [0, 25, 45, 200], right=False,
13
+ labels=["gen z", "millenial", "gen x+"]).astype(object)
@@ -0,0 +1,42 @@
1
+ """Kumar et al.: 0-4 toxicity scores (shifted to 1-5) of 107,620 comments, 5 raters each, with the rater's attributes."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import pandas as pd
8
+
9
+ from ..spec import DatasetSpec
10
+
11
+ COLUMNS = ["toxic_score", "gender", "race", "age_range", "political_affilation", "religion_important"]
12
+
13
+
14
+ def load(root: Path) -> pd.DataFrame:
15
+ """The file is JSON lines: one comment per line with its list of ratings (about 450 MB, read line by line)."""
16
+ rows = []
17
+ with open(Path(root) / "datasets" / "kumar" / "toxicity_ratings.json", encoding="utf-8") as f:
18
+ for line in f:
19
+ comment = json.loads(line)
20
+ for rating in comment["ratings"]:
21
+ rows.append((comment["comment_id"], comment["comment"]) + tuple(rating.get(c) for c in COLUMNS))
22
+ out = pd.DataFrame(rows, columns=["item_id", "text"] + COLUMNS)
23
+ out["rating"] = out["toxic_score"] + 1 # 0-4 -> 1-5
24
+ return out
25
+
26
+
27
+ SPEC = DatasetSpec(
28
+ name="kumar",
29
+ load=load,
30
+ dimensions={
31
+ "gender": ("gender", {"Male": "Male", "Female": "Female"}),
32
+ "race": ("race", {"White": "White", "Black or African American": "Black", "Asian": "Asian"}),
33
+ "age": ("age_range", {"Under 18": "gen z", "18 - 24": "gen z", "25 - 34": "millenial", "35 - 44": "millenial",
34
+ "45 - 54": "gen x+", "55 - 64": "gen x+", "65 or older": "gen x+"}),
35
+ "politics": ("political_affilation", {"Liberal": "liberal", "Independent": "independent", "Conservative": "conservative"}),
36
+ "religion": ("religion_important", {"Not important": "not_high", "Not too important": "not_high",
37
+ "Somewhat important": "not_high", "Very important": "high"}),
38
+ },
39
+ item_word="comment",
40
+ description="Kumar, toxic_score + 1 (1-5)",
41
+ rating_column="toxic_score", # no rater id in the data
42
+ )
@@ -0,0 +1,47 @@
1
+ """Measuring Hate Speech (Hugging Face `ucberkeley-dlab/measuring-hate-speech`): the `hatespeech` item, No / Unclear / Yes -> 1-3."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import pandas as pd
8
+
9
+ from ..spec import DatasetSpec
10
+ from .groups import age_generations
11
+
12
+ URL = "https://huggingface.co/datasets/ucberkeley-dlab/measuring-hate-speech/resolve/refs%2Fconvert%2Fparquet/default/train/0000.parquet"
13
+ COLUMNS = ["comment_id", "annotator_id", "text", "hatespeech", "annotator_gender", "annotator_age", "annotator_ideology"]
14
+
15
+
16
+ def load(root: Path, path: Optional[Path] = None) -> pd.DataFrame:
17
+ """Reads ``datasets/mhs/train.parquet`` when it exists (or ``path``), otherwise downloads the file from Hugging Face."""
18
+ local = Path(path) if path else Path(root) / "datasets" / "mhs" / "train.parquet"
19
+ raw = pd.read_parquet(local if local.exists() else URL, columns=COLUMNS)
20
+ out = raw.rename(columns={"comment_id": "item_id"})
21
+ out["rating"] = out["hatespeech"].astype(int) + 1 # 1 = No, 2 = Unclear, 3 = Yes
22
+ return out
23
+
24
+
25
+ def _at_least_four_ratings(table: pd.DataFrame) -> pd.DataFrame:
26
+ """A comment needs 4 ratings to have 2 + 2; the others cannot be AU."""
27
+ return table[table.groupby("item_id")["rating"].transform("size") >= 4]
28
+
29
+
30
+ IDEOLOGY = {"extremely_liberal": "liberal", "liberal": "liberal", "slightly_liberal": "liberal", "neutral": "neutral",
31
+ "slightly_conservative": "conservative", "conservative": "conservative", "extremely_conservative": "conservative"}
32
+
33
+ SPEC = DatasetSpec(
34
+ name="mhs",
35
+ load=load,
36
+ dimensions={
37
+ "gender": ("annotator_gender", {"male": "male", "female": "female"}),
38
+ "age": ("annotator_age", age_generations),
39
+ "ideology": ("annotator_ideology", IDEOLOGY),
40
+ },
41
+ scale_max=3,
42
+ prefilter=_at_least_four_ratings,
43
+ item_word="comment",
44
+ description="Measuring Hate Speech, `hatespeech` item (1 = No, 2 = Unclear, 3 = Yes)",
45
+ annotator_column="annotator_id",
46
+ rating_column="hatespeech",
47
+ )
@@ -0,0 +1,39 @@
1
+ """POPQUORN: 1-5 offensiveness ratings of 1,500 texts, with the rater's gender, race, age band, education and occupation."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ import pandas as pd
7
+
8
+ from ..spec import DatasetSpec
9
+
10
+ # the ten age bands of the file, grouped like the generations of DICES
11
+ AGE_BANDS = {"18-24": "gen z",
12
+ "25-29": "millenial", "30-34": "millenial", "35-39": "millenial", "40-44": "millenial",
13
+ "45-49": "gen x+", "50-54": "gen x+", "54-59": "gen x+", "60-64": "gen x+", ">65": "gen x+"}
14
+
15
+
16
+ def load(root: Path) -> pd.DataFrame:
17
+ raw = pd.read_csv(Path(root) / "datasets" / "popquorn" / "popquorn.csv")
18
+ return raw.rename(columns={"instance_id": "item_id", "offensiveness": "rating"})
19
+
20
+
21
+ def _keep(*values):
22
+ return {v: v for v in values} # keep these values as they are; every other value is removed
23
+
24
+
25
+ SPEC = DatasetSpec(
26
+ name="popquorn",
27
+ load=load,
28
+ dimensions={
29
+ "gender": ("gender", _keep("Man", "Woman")),
30
+ "race": ("race", _keep("White", "Black or African American", "Asian")),
31
+ "age": ("age", AGE_BANDS),
32
+ "education": ("education", _keep("College degree", "High school diploma or equivalent", "Graduate degree")),
33
+ "occupation": ("occupation", _keep("Employed", "Self-employed", "Unemployed", "Retired", "Student", "Homemaker")),
34
+ },
35
+ item_word="text",
36
+ description="POPQUORN, 1-5 offensiveness",
37
+ annotator_column="user_id",
38
+ rating_column="offensiveness",
39
+ )