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.
- polaudit-1.0.0/LICENSE +21 -0
- polaudit-1.0.0/PKG-INFO +106 -0
- polaudit-1.0.0/README.md +69 -0
- polaudit-1.0.0/pyproject.toml +44 -0
- polaudit-1.0.0/setup.cfg +4 -0
- polaudit-1.0.0/src/polaudit/__init__.py +39 -0
- polaudit-1.0.0/src/polaudit/agreement.py +61 -0
- polaudit-1.0.0/src/polaudit/annotation.py +218 -0
- polaudit-1.0.0/src/polaudit/attribution_bootstrap.py +86 -0
- polaudit-1.0.0/src/polaudit/datasets/__init__.py +13 -0
- polaudit-1.0.0/src/polaudit/datasets/attitudes.py +69 -0
- polaudit-1.0.0/src/polaudit/datasets/groups.py +13 -0
- polaudit-1.0.0/src/polaudit/datasets/kumar.py +42 -0
- polaudit-1.0.0/src/polaudit/datasets/mhs.py +47 -0
- polaudit-1.0.0/src/polaudit/datasets/popquorn.py +39 -0
- polaudit-1.0.0/src/polaudit/eda.py +258 -0
- polaudit-1.0.0/src/polaudit/eda_dataset.py +173 -0
- polaudit-1.0.0/src/polaudit/env.py +88 -0
- polaudit-1.0.0/src/polaudit/export.py +73 -0
- polaudit-1.0.0/src/polaudit/kappa_groups.py +323 -0
- polaudit-1.0.0/src/polaudit/model_comparison.py +57 -0
- polaudit-1.0.0/src/polaudit/model_eda.py +117 -0
- polaudit-1.0.0/src/polaudit/models.py +258 -0
- polaudit-1.0.0/src/polaudit/names.py +15 -0
- polaudit-1.0.0/src/polaudit/persona_annotation.py +194 -0
- polaudit-1.0.0/src/polaudit/persona_kappa.py +136 -0
- polaudit-1.0.0/src/polaudit/pipeline.py +196 -0
- polaudit-1.0.0/src/polaudit/poles.py +190 -0
- polaudit-1.0.0/src/polaudit/prompt_sensitivity.py +56 -0
- polaudit-1.0.0/src/polaudit/prompts.py +175 -0
- polaudit-1.0.0/src/polaudit/sampling.py +65 -0
- polaudit-1.0.0/src/polaudit/scoring.py +81 -0
- polaudit-1.0.0/src/polaudit/shuffling_robustness.py +361 -0
- polaudit-1.0.0/src/polaudit/spec.py +33 -0
- polaudit-1.0.0/src/polaudit/toy.py +88 -0
- polaudit-1.0.0/src/polaudit/unimodality.py +209 -0
- polaudit-1.0.0/src/polaudit/utils.py +55 -0
- polaudit-1.0.0/src/polaudit.egg-info/PKG-INFO +106 -0
- polaudit-1.0.0/src/polaudit.egg-info/SOURCES.txt +56 -0
- polaudit-1.0.0/src/polaudit.egg-info/dependency_links.txt +1 -0
- polaudit-1.0.0/src/polaudit.egg-info/requires.txt +25 -0
- polaudit-1.0.0/src/polaudit.egg-info/top_level.txt +1 -0
- polaudit-1.0.0/tests/test_agreement.py +38 -0
- polaudit-1.0.0/tests/test_annotation.py +176 -0
- polaudit-1.0.0/tests/test_attribution_bootstrap.py +52 -0
- polaudit-1.0.0/tests/test_eda.py +55 -0
- polaudit-1.0.0/tests/test_eda_dataset.py +87 -0
- polaudit-1.0.0/tests/test_export.py +47 -0
- polaudit-1.0.0/tests/test_kappa_groups.py +128 -0
- polaudit-1.0.0/tests/test_load_results.py +47 -0
- polaudit-1.0.0/tests/test_models.py +146 -0
- polaudit-1.0.0/tests/test_persona_annotation.py +96 -0
- polaudit-1.0.0/tests/test_persona_kappa.py +48 -0
- polaudit-1.0.0/tests/test_run_dataset_cache.py +40 -0
- polaudit-1.0.0/tests/test_sampling.py +62 -0
- polaudit-1.0.0/tests/test_save_prompts.py +21 -0
- polaudit-1.0.0/tests/test_toy.py +52 -0
- 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.
|
polaudit-1.0.0/PKG-INFO
ADDED
|
@@ -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
|
+

|
|
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
|
+

|
|
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.
|
polaudit-1.0.0/README.md
ADDED
|
@@ -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
|
+

|
|
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
|
+

|
|
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"]
|
polaudit-1.0.0/setup.cfg
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"]
|
|
@@ -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)
|