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 +39 -0
- polaudit/agreement.py +61 -0
- polaudit/annotation.py +218 -0
- polaudit/attribution_bootstrap.py +86 -0
- polaudit/datasets/__init__.py +13 -0
- polaudit/datasets/attitudes.py +69 -0
- polaudit/datasets/groups.py +13 -0
- polaudit/datasets/kumar.py +42 -0
- polaudit/datasets/mhs.py +47 -0
- polaudit/datasets/popquorn.py +39 -0
- polaudit/eda.py +258 -0
- polaudit/eda_dataset.py +173 -0
- polaudit/env.py +88 -0
- polaudit/export.py +73 -0
- polaudit/kappa_groups.py +323 -0
- polaudit/model_comparison.py +57 -0
- polaudit/model_eda.py +117 -0
- polaudit/models.py +258 -0
- polaudit/names.py +15 -0
- polaudit/persona_annotation.py +194 -0
- polaudit/persona_kappa.py +136 -0
- polaudit/pipeline.py +196 -0
- polaudit/poles.py +190 -0
- polaudit/prompt_sensitivity.py +56 -0
- polaudit/prompts.py +175 -0
- polaudit/sampling.py +65 -0
- polaudit/scoring.py +81 -0
- polaudit/shuffling_robustness.py +361 -0
- polaudit/spec.py +33 -0
- polaudit/toy.py +88 -0
- polaudit/unimodality.py +209 -0
- polaudit/utils.py +55 -0
- polaudit-1.0.0.dist-info/METADATA +106 -0
- polaudit-1.0.0.dist-info/RECORD +37 -0
- polaudit-1.0.0.dist-info/WHEEL +5 -0
- polaudit-1.0.0.dist-info/licenses/LICENSE +21 -0
- polaudit-1.0.0.dist-info/top_level.txt +1 -0
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
|
+
)
|
polaudit/datasets/mhs.py
ADDED
|
@@ -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
|
+
)
|