polaudit 1.0.0__tar.gz

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.
Files changed (58) hide show
  1. polaudit-1.0.0/LICENSE +21 -0
  2. polaudit-1.0.0/PKG-INFO +106 -0
  3. polaudit-1.0.0/README.md +69 -0
  4. polaudit-1.0.0/pyproject.toml +44 -0
  5. polaudit-1.0.0/setup.cfg +4 -0
  6. polaudit-1.0.0/src/polaudit/__init__.py +39 -0
  7. polaudit-1.0.0/src/polaudit/agreement.py +61 -0
  8. polaudit-1.0.0/src/polaudit/annotation.py +218 -0
  9. polaudit-1.0.0/src/polaudit/attribution_bootstrap.py +86 -0
  10. polaudit-1.0.0/src/polaudit/datasets/__init__.py +13 -0
  11. polaudit-1.0.0/src/polaudit/datasets/attitudes.py +69 -0
  12. polaudit-1.0.0/src/polaudit/datasets/groups.py +13 -0
  13. polaudit-1.0.0/src/polaudit/datasets/kumar.py +42 -0
  14. polaudit-1.0.0/src/polaudit/datasets/mhs.py +47 -0
  15. polaudit-1.0.0/src/polaudit/datasets/popquorn.py +39 -0
  16. polaudit-1.0.0/src/polaudit/eda.py +258 -0
  17. polaudit-1.0.0/src/polaudit/eda_dataset.py +173 -0
  18. polaudit-1.0.0/src/polaudit/env.py +88 -0
  19. polaudit-1.0.0/src/polaudit/export.py +73 -0
  20. polaudit-1.0.0/src/polaudit/kappa_groups.py +323 -0
  21. polaudit-1.0.0/src/polaudit/model_comparison.py +57 -0
  22. polaudit-1.0.0/src/polaudit/model_eda.py +117 -0
  23. polaudit-1.0.0/src/polaudit/models.py +258 -0
  24. polaudit-1.0.0/src/polaudit/names.py +15 -0
  25. polaudit-1.0.0/src/polaudit/persona_annotation.py +194 -0
  26. polaudit-1.0.0/src/polaudit/persona_kappa.py +136 -0
  27. polaudit-1.0.0/src/polaudit/pipeline.py +196 -0
  28. polaudit-1.0.0/src/polaudit/poles.py +190 -0
  29. polaudit-1.0.0/src/polaudit/prompt_sensitivity.py +56 -0
  30. polaudit-1.0.0/src/polaudit/prompts.py +175 -0
  31. polaudit-1.0.0/src/polaudit/sampling.py +65 -0
  32. polaudit-1.0.0/src/polaudit/scoring.py +81 -0
  33. polaudit-1.0.0/src/polaudit/shuffling_robustness.py +361 -0
  34. polaudit-1.0.0/src/polaudit/spec.py +33 -0
  35. polaudit-1.0.0/src/polaudit/toy.py +88 -0
  36. polaudit-1.0.0/src/polaudit/unimodality.py +209 -0
  37. polaudit-1.0.0/src/polaudit/utils.py +55 -0
  38. polaudit-1.0.0/src/polaudit.egg-info/PKG-INFO +106 -0
  39. polaudit-1.0.0/src/polaudit.egg-info/SOURCES.txt +56 -0
  40. polaudit-1.0.0/src/polaudit.egg-info/dependency_links.txt +1 -0
  41. polaudit-1.0.0/src/polaudit.egg-info/requires.txt +25 -0
  42. polaudit-1.0.0/src/polaudit.egg-info/top_level.txt +1 -0
  43. polaudit-1.0.0/tests/test_agreement.py +38 -0
  44. polaudit-1.0.0/tests/test_annotation.py +176 -0
  45. polaudit-1.0.0/tests/test_attribution_bootstrap.py +52 -0
  46. polaudit-1.0.0/tests/test_eda.py +55 -0
  47. polaudit-1.0.0/tests/test_eda_dataset.py +87 -0
  48. polaudit-1.0.0/tests/test_export.py +47 -0
  49. polaudit-1.0.0/tests/test_kappa_groups.py +128 -0
  50. polaudit-1.0.0/tests/test_load_results.py +47 -0
  51. polaudit-1.0.0/tests/test_models.py +146 -0
  52. polaudit-1.0.0/tests/test_persona_annotation.py +96 -0
  53. polaudit-1.0.0/tests/test_persona_kappa.py +48 -0
  54. polaudit-1.0.0/tests/test_run_dataset_cache.py +40 -0
  55. polaudit-1.0.0/tests/test_sampling.py +62 -0
  56. polaudit-1.0.0/tests/test_save_prompts.py +21 -0
  57. polaudit-1.0.0/tests/test_toy.py +52 -0
  58. polaudit-1.0.0/tests/test_unimodality.py +80 -0
polaudit-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The polaudit authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: polaudit
3
+ Version: 1.0.0
4
+ Summary: An audit framework for LLM alignment with human subgroups, based on polarization
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/Swkratis210204/polalign
7
+ Project-URL: Documentation, https://github.com/Swkratis210204/polalign#readme
8
+ Project-URL: Repository, https://github.com/Swkratis210204/polalign
9
+ Keywords: polarization,LLM alignment,subgroups,annotator disagreement,audit,toxicity,unimodality,perspectivism
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: numpy
18
+ Requires-Dist: pandas
19
+ Requires-Dist: scipy>=1.9
20
+ Requires-Dist: ndfu<1,>=0.9.3
21
+ Requires-Dist: tqdm
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest; extra == "test"
24
+ Provides-Extra: plots
25
+ Requires-Dist: matplotlib; extra == "plots"
26
+ Provides-Extra: openai
27
+ Requires-Dist: openai; extra == "openai"
28
+ Provides-Extra: gemini
29
+ Requires-Dist: google-genai; extra == "gemini"
30
+ Provides-Extra: oci
31
+ Requires-Dist: oci; extra == "oci"
32
+ Provides-Extra: all-models
33
+ Requires-Dist: openai; extra == "all-models"
34
+ Requires-Dist: google-genai; extra == "all-models"
35
+ Requires-Dist: oci; extra == "all-models"
36
+ Dynamic: license-file
37
+
38
+ # polaudit
39
+
40
+ **An audit framework for LLM alignment with human subgroups, based on polarization.** Whose perspective does an LLM's label reflect? When people disagree about a text, a language model's single label sides with someone.
41
+ `polaudit` finds the texts where annotators are polarized, finds which annotator characteristic (gender, politics, age, ...) explains the split,
42
+ and measures which side each model takes.
43
+
44
+ ![A model must choose a side on a polarized text](https://raw.githubusercontent.com/Swkratis210204/polalign/main/polaudit/docs/images/polarized_case.png)
45
+
46
+ ## The audit in four steps
47
+
48
+ 1. **Detect polarization.** Each text gets a DFU score (distance from unimodality). A text with DFU above 0 is polarized.
49
+ 2. **Attribute it.** A polarized text is attributed to a characteristic when every group of that characteristic is internally unimodal (a-posteriori unimodality, AU).
50
+ Groups in fewer than 10 attributed texts are removed.
51
+ 3. **Ask the models.** Your prompt, your models, optionally with a persona.
52
+ 4. **Measure alignment.** Cohen's kappa between the model's labels and each group's position, with bootstrap intervals and a chance check.
53
+
54
+ ![The audit framework](https://raw.githubusercontent.com/Swkratis210204/polalign/main/polaudit/docs/images/framework.png)
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ pip install polaudit # the AU analysis
60
+ pip install "polaudit[plots,openai,gemini]" # figures and the model providers you use
61
+ ```
62
+
63
+ ## Try it in a minute (no data, no keys)
64
+
65
+ ```python
66
+ from polaudit import run_dataset
67
+ from polaudit.toy import toy_spec, toy_ratings
68
+
69
+ result = run_dataset(toy_spec(), raw=toy_ratings())
70
+ print(result.describe())
71
+ result.per_dimension # attributed texts per characteristic
72
+ ```
73
+
74
+ ## Your own data, prompt and model
75
+
76
+ ```python
77
+ from polaudit import DatasetSpec, ModelHub, annotate, load_model_inputs, run_dataset, save_au_tables
78
+
79
+ spec = DatasetSpec(
80
+ name="mydata",
81
+ load=lambda root: my_dataframe, # columns: item_id, text, rating, plus one column per characteristic
82
+ dimensions={"gender": ("gender", {"m": "man", "f": "woman"}),
83
+ "politics": ("politics", {"l": "left", "r": "right"})},
84
+ )
85
+ result = run_dataset(spec, raw=my_dataframe, min_au_texts=10) # min_au_texts=0 keeps all AU texts
86
+ save_au_tables({"mydata": result.au}, "results")
87
+
88
+ def my_prompt(texts, item):
89
+ return f"Rate how toxic each {item} is from 1 to 5.\nFormat: score=<n>, label=<toxic/civil>\n\n{texts}"
90
+
91
+ annotate(ModelHub.from_env()["chatgpt"], load_model_inputs("results/aposteriori_for_models.csv"), "results", prompt=my_prompt, run="baseline")
92
+ ```
93
+
94
+ ## Full documentation
95
+
96
+ This page is the short version. The full documentation lives on GitHub, with nine tutorials (a short document and a runnable notebook each), every parameter, and the code
97
+ that reproduces the paper:
98
+
99
+ - **[Full README on GitHub](https://github.com/Swkratis210204/polalign#readme)**: overview, repository layout, all tutorials, reproducing the paper
100
+ - [Tutorials: documents](https://github.com/Swkratis210204/polalign/tree/main/polaudit/docs) and [notebooks](https://github.com/Swkratis210204/polalign/tree/main/polaudit/notebooks)
101
+ - [All parameters](https://github.com/Swkratis210204/polalign/blob/main/polaudit/docs/parameters.md)
102
+ - [Reference: API keys, models, prompts, outputs, troubleshooting](https://github.com/Swkratis210204/polalign/blob/main/polaudit/docs/reference.md)
103
+
104
+ ## The paper
105
+
106
+ This package is the code of *Polarization Auditing Framework for LLMs: a Toxicity Detection Study* (authors anonymous for review) and follows the current version of the paper.
@@ -0,0 +1,69 @@
1
+ # polaudit
2
+
3
+ **An audit framework for LLM alignment with human subgroups, based on polarization.** Whose perspective does an LLM's label reflect? When people disagree about a text, a language model's single label sides with someone.
4
+ `polaudit` finds the texts where annotators are polarized, finds which annotator characteristic (gender, politics, age, ...) explains the split,
5
+ and measures which side each model takes.
6
+
7
+ ![A model must choose a side on a polarized text](https://raw.githubusercontent.com/Swkratis210204/polalign/main/polaudit/docs/images/polarized_case.png)
8
+
9
+ ## The audit in four steps
10
+
11
+ 1. **Detect polarization.** Each text gets a DFU score (distance from unimodality). A text with DFU above 0 is polarized.
12
+ 2. **Attribute it.** A polarized text is attributed to a characteristic when every group of that characteristic is internally unimodal (a-posteriori unimodality, AU).
13
+ Groups in fewer than 10 attributed texts are removed.
14
+ 3. **Ask the models.** Your prompt, your models, optionally with a persona.
15
+ 4. **Measure alignment.** Cohen's kappa between the model's labels and each group's position, with bootstrap intervals and a chance check.
16
+
17
+ ![The audit framework](https://raw.githubusercontent.com/Swkratis210204/polalign/main/polaudit/docs/images/framework.png)
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install polaudit # the AU analysis
23
+ pip install "polaudit[plots,openai,gemini]" # figures and the model providers you use
24
+ ```
25
+
26
+ ## Try it in a minute (no data, no keys)
27
+
28
+ ```python
29
+ from polaudit import run_dataset
30
+ from polaudit.toy import toy_spec, toy_ratings
31
+
32
+ result = run_dataset(toy_spec(), raw=toy_ratings())
33
+ print(result.describe())
34
+ result.per_dimension # attributed texts per characteristic
35
+ ```
36
+
37
+ ## Your own data, prompt and model
38
+
39
+ ```python
40
+ from polaudit import DatasetSpec, ModelHub, annotate, load_model_inputs, run_dataset, save_au_tables
41
+
42
+ spec = DatasetSpec(
43
+ name="mydata",
44
+ load=lambda root: my_dataframe, # columns: item_id, text, rating, plus one column per characteristic
45
+ dimensions={"gender": ("gender", {"m": "man", "f": "woman"}),
46
+ "politics": ("politics", {"l": "left", "r": "right"})},
47
+ )
48
+ result = run_dataset(spec, raw=my_dataframe, min_au_texts=10) # min_au_texts=0 keeps all AU texts
49
+ save_au_tables({"mydata": result.au}, "results")
50
+
51
+ def my_prompt(texts, item):
52
+ return f"Rate how toxic each {item} is from 1 to 5.\nFormat: score=<n>, label=<toxic/civil>\n\n{texts}"
53
+
54
+ annotate(ModelHub.from_env()["chatgpt"], load_model_inputs("results/aposteriori_for_models.csv"), "results", prompt=my_prompt, run="baseline")
55
+ ```
56
+
57
+ ## Full documentation
58
+
59
+ This page is the short version. The full documentation lives on GitHub, with nine tutorials (a short document and a runnable notebook each), every parameter, and the code
60
+ that reproduces the paper:
61
+
62
+ - **[Full README on GitHub](https://github.com/Swkratis210204/polalign#readme)**: overview, repository layout, all tutorials, reproducing the paper
63
+ - [Tutorials: documents](https://github.com/Swkratis210204/polalign/tree/main/polaudit/docs) and [notebooks](https://github.com/Swkratis210204/polalign/tree/main/polaudit/notebooks)
64
+ - [All parameters](https://github.com/Swkratis210204/polalign/blob/main/polaudit/docs/parameters.md)
65
+ - [Reference: API keys, models, prompts, outputs, troubleshooting](https://github.com/Swkratis210204/polalign/blob/main/polaudit/docs/reference.md)
66
+
67
+ ## The paper
68
+
69
+ This package is the code of *Polarization Auditing Framework for LLMs: a Toxicity Detection Study* (authors anonymous for review) and follows the current version of the paper.
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "polaudit"
7
+ version = "1.0.0"
8
+ readme = "README.md"
9
+ license = {text = "MIT"}
10
+ classifiers = [
11
+ "License :: OSI Approved :: MIT License",
12
+ "Programming Language :: Python :: 3",
13
+ "Intended Audience :: Science/Research",
14
+ "Topic :: Scientific/Engineering :: Information Analysis",
15
+ ]
16
+ description = "An audit framework for LLM alignment with human subgroups, based on polarization"
17
+ requires-python = ">=3.9"
18
+ keywords = ["polarization", "LLM alignment", "subgroups", "annotator disagreement", "audit", "toxicity", "unimodality", "perspectivism"]
19
+ dependencies = [
20
+ "numpy",
21
+ "pandas",
22
+ "scipy>=1.9",
23
+ "ndfu>=0.9.3,<1",
24
+ "tqdm",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ test = ["pytest"]
29
+ plots = ["matplotlib"] # the figures of eda_figures / save_eda
30
+ openai = ["openai"] # ChatGPT, Mistral, DeepSeek, Ollama (Llama)
31
+ gemini = ["google-genai"]
32
+ oci = ["oci"] # Grok on Oracle OCI
33
+ all-models = ["openai", "google-genai", "oci"]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/Swkratis210204/polalign"
37
+ Documentation = "https://github.com/Swkratis210204/polalign#readme"
38
+ Repository = "https://github.com/Swkratis210204/polalign"
39
+
40
+ [tool.setuptools.packages.find]
41
+ where = ["src"]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"]
@@ -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
@@ -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)