Code for democratic preference aggregation with personalized reward models. The pipeline:
- learns individual user reward models on PRISM using LoRe (low-rank reward modeling),
- simulates historical users via the ProgressGym HistLlama century-conditioned models,
- forms a jury of those users and aggregates their per-response rankings into a single democratic ranking over candidate responses.
If you use this code, please cite the corresponding paper:
@misc{2026apa,
title={Adaptive Pluralistic Alignment: A pipeline for dynamic artificial democracy},
author={Rachel Freedman},
year={2026},
eprint={2605.01642},
archivePrefix={arXiv},
primaryClass={cs.LG},
url={https://arxiv.org/abs/2605.01642},
}This code adapts the LoRe implementation from https://github.com/facebookresearch/LoRe. If you use this portion of the code, please also cite the LoRe paper:
@misc{bose2025lore,
title={LoRe: Personalizing LLMs via Low-Rank Reward Modeling},
author={Avinandan Bose and Zhihan Xiong and Yuejie Chi and Simon Shaolei Du and Lin Xiao and Maryam Fazel},
year={2025},
eprint={2504.14439},
archivePrefix={arXiv},
primaryClass={cs.LG},
url={https://arxiv.org/abs/2504.14439},
}cd /home/rachel/APA
# Set up the environment
source setup_uv.sh
uv sync
# Smoke test
uv run python -m apa.config # imports
uv run pytest -m "not slow" -q # ~30s, all fast testsYou can quickly and exactly reproduce the paper vote results by reusing the basis functions and PRISM user weights that we pre-compute by running:
bash experiments/scripts/train_user_weights_C016_C020.sh
bash experiments/scripts/reproduce_paper_votes.shThat wrapper runs both votes — run_vote_C016_C020.sh (regular slate)
and run_vote_C016_C020_simple.sh (yes/no slate) — against our pre-computed checkpoints:
experiments/checkpoints/V_K8.pt # LoRe basis
experiments/checkpoints/W_seen_K8.pt # PRISM jury votersYou can also reproduce the full pipeline by learning the reward bases and PRISM weights and regenerated historical user preferences yourself. This will take a few hours with 4× A100/A6000 GPUs. The results will be comparable, but not identical, to the checkpoints above and results reported in the paper.
Prerequisites:
- PRISM raw data (downloaded automatically by
apa.load_prismfrom HuggingFace). - 1× A100/A6000 (≥40 GB) for steps 1 and 3–6.
- 4× A100/A6000 (each ≥35 GB free) for step 2 (70B HistLlama via vLLM
with
tensor_parallel_size=4).
uv run python -m apa.load_prism --split both
uv run python -m apa.train_lore_bases --K_list 0,1,8Writes V_K{0,1,8}.pt and W_seen_K{0,1,8}.pt under $NAS_BASE/models/.
V_K8.pt is the basis the rest of the pipeline reuses.
bash experiments/scripts/generate_prefs_C016_C020.shReads:
experiments/chosen_questions.jsonl— 500 PRISM questions where moral consensus is expected to vary across centuries (produced byscripts/select_time_varying_questions.py).experiments/profiles_C016_C020.jsonl— the 20 paper personas (10 from C016 + 10 from C020).
Writes hist_prefs_C{016,020}.jsonl (eval_prefs format) and
hist_prefs_C{016,020}_raw.json (full reasoning + logprobs) under
experiments/synthetic_prefs_C016_C020/. Each preference pair is judged
via a two-stage CoT chat-template flow (stage 1 reasoning, stage 2
guided-decode commit to {"X","Y"}); both orderings are averaged to
cancel the model's ordering bias.
uv run python -m experiments.filter_output filter \
--input experiments/synthetic_prefs_C016_C020/hist_prefs_C016_raw.json \
experiments/synthetic_prefs_C016_C020/hist_prefs_C020_raw.json \
--output experiments/synthetic_prefs_C016_C020/hist_prefs_all_filtered.jsonl \
--min-records-per-user 5bash experiments/scripts/train_user_weights_C016_C020.shDefaults to fitting against the pre-computed experiments/checkpoints/V_K8.pt. Override
with V_CHECKPOINT=$NAS_BASE/models/V_K8.pt bash … to use the V you
just trained in step 1.
bash experiments/scripts/run_vote_C016_C020.sh
bash experiments/scripts/run_vote_C016_C020_simple.shEach script defaults to the repo-tracked checkpoints; override with the
V_CHECKPOINT / PRISM_USERS / ADAPTED env vars to use freshly
trained ones.
APA/
├── apa/
│ ├── _logging.py # Shared timestamped logger
│ ├── config.py # Paths and dataclass configs
│ ├── load_prism.py # PRISM load + Skywork embedding
│ ├── train_lore_bases.py # LoRe basis (V) training
│ ├── lore_adapt.py # Few-shot W adaptation + LoReScorer
│ ├── democratic_response.py # Jury → vote orchestrator
│ ├── vote_analysis.py # Audit-log post-processing + reports
│ ├── synthetic_prefs/
│ │ ├── historical_prefs.py # HistLlama-driven preference generation
│ │ ├── eval_prefs.py # LoRe suitability metrics
│ │ ├── sample_data.py # PRISM/random baseline samplers
│ │ ├── profiles.jsonl # Canonical 90 personas (10 / century)
│ │ └── curated_questions.txt # Curated value-laden PRISM questions
│ └── levers/ # Pluggable strategies (see below)
│ ├── slate_generation.py
│ ├── voter_sampling.py
│ ├── voter_aggregation.py
│ └── query_selection.py
├── scripts/ # Generic / validation pipelines
│ ├── select_time_varying_questions.py
│ ├── compare_metrics.py
│ ├── run_hist_prefs_full.sh
│ ├── run_all_centuries_70b.sh
│ └── run_hist_adapt.sh
├── experiments/ # Paper experiment + figures
│ ├── scripts/
│ │ ├── generate_prefs_C016_C020.sh
│ │ ├── train_user_weights_C016_C020.sh
│ │ ├── run_vote_C016_C020.sh
│ │ └── run_vote_C016_C020_simple.sh
│ ├── filter_output.py # 3-stage preference filter
│ ├── figs.py # Paper figures
│ ├── utils.py # extract-question-ids helper
│ ├── chosen_questions.jsonl # 500 time-varying PRISM questions (input)
│ ├── profiles_C016_C020.jsonl # 20 paper personas (input)
│ ├── query_responses.jsonl # Vote slate (input)
│ ├── query_responses_simple.jsonl # Yes/no vote slate (input)
│ ├── synthetic_prefs_C016_C020/ # Step-2/3 outputs (tracked)
│ ├── vote_C016_C020/ # Step-5 outputs (tracked)
│ ├── vote_C016_C020_simple/ # Step-5 outputs (tracked)
│ └── figs/ # Step-6 outputs (tracked)
├── tests/ # Fast (~30s) + 2 slow (~30 min total)
├── pyproject.toml
├── setup_uv.sh
└── README.md
The four lever modules under apa/levers/ factor out the strategy
choices made by the pipeline. Production code dispatches by name where
applicable.
| Lever | Module | Strategies | Used by |
|---|---|---|---|
| Response generation | slate_generation.py |
temperature_sampling |
democratic_response |
| Voter sampling (jury composition) | voter_sampling.py |
random, stratified, weighted, temporal_mix, per_group_sampling |
democratic_response |
| Ranking aggregation | voter_aggregation.py |
borda_count, plurality, copeland, instant_runoff |
democratic_response |
| Question selection | query_selection.py |
random_subset, select_by_ids |
historical_prefs (which questions to put to a persona) |
per_group_sampling is the lever that backs the --jury_sources flag —
e.g. --jury_sources "C16,C20,prism:10" means all C016 voters + all
C020 voters + 10 randomly-sampled PRISM voters. It also exposes
parse_jury_source_spec for parsing those flag tokens.
uv run pytest -m "not slow" -q # ~30s
uv run pytest -q # ~30 min (includes test_lore.py + test_suitability.py)