Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,22 @@ jobs:
uv run --locked peermatch extract-keyphrases
--documents examples/documents.json --experts examples/experts.json
--directory keyphrases-smoke
- run: >-
uv run --locked peermatch extract-keyphrases
--documents examples/centroid/documents.json --experts examples/centroid/experts.json
--directory centroid-keyphrases-smoke
- run: >-
uv run --locked peermatch expertise-centroid
--keyphrases centroid-keyphrases-smoke
--documents examples/centroid/documents.json --experts examples/centroid/experts.json
--train examples/centroid/train.jsonl --validation examples/centroid/validation.jsonl
--dimensions 2 --epochs 3 --directory centroid-run-smoke
- run: >-
uv run --locked peermatch match-affinity
--documents centroid-run-smoke/documents.json --experts centroid-run-smoke/experts.json
--conflicts centroid-run-smoke/conflicts.json
--affinities centroid-run-smoke/affinities.csv
--config examples/expertise/match-config.json --output centroid-plan-smoke.json
- run: >-
uv run --locked peermatch expertise-embedding
--snapshot examples/expertise/snapshot
Expand Down Expand Up @@ -81,6 +97,7 @@ jobs:
.ci-wheel-check/bin/python -m pip install --disable-pip-version-check --no-deps dist/*.whl
.ci-wheel-check/bin/python -c "import peermatchlab; print(peermatchlab.__version__)"
.ci-wheel-check/bin/python -c "from peermatchlab.keyphrases import KeyphraseConfig, rank_keyphrases; assert rank_keyphrases('alpha beta', config=KeyphraseConfig())"
.ci-wheel-check/bin/python -c "from peermatchlab import CentroidConfig, train_keyphrase_centroid; assert CentroidConfig and train_keyphrase_centroid"
.ci-wheel-check/bin/python -c "from peermatchlab.what_if import WhatIfPlan, compare_what_if; assert WhatIfPlan and compare_what_if"
.ci-wheel-check/bin/peermatch --help

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ All notable changes are documented here. The project follows semantic versioning

_No changes yet._

## 0.16.0 - 2026-09-20

- Added an original, bounded local trainable keyphrase-centroid expertise
workflow: strict hashed TextRank-style source interchange, train/validation
triplets split by submission ID, deterministic pairwise logistic SGD,
first-best validation MAP checkpoint, replayable source-bound model, and
holdout-only affinities consumable by `match-affinity`. It uses synthetic
fixtures and does not claim OpenReview Expertise model/score parity.

## 0.15.0 - 2026-09-20

- Added bounded, deterministic local capacity/conflict what-if comparisons with
Expand Down
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ authors:
- family-names: "Yan"
given-names: "Weiping"
alias: "appleweiping"
version: 0.15.0
version: 0.16.0
date-released: 2026-09-20
license: MIT
repository-code: "https://github.com/appleweiping/PeerMatchLab"
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ Most matching prototypes stop after computing pairwise similarity. Real allocati
publication/profile evidence and additive term explanations.
- Offline, pluggable SPECTER-family embedding interchange with hashed synthetic fixtures;
no neural encoder or model weights are bundled.
- Bounded [trainable keyphrase-centroid expertise](docs/trainable-centroid.md) with
disjoint train/validation submission IDs, pairwise logistic updates,
first-best validation MAP checkpoint, and holdout-only match-ready affinities.
- Content similarity, explicit topic overlap, bid preference, publication recency, and seniority components.
- Exact hard-conflict and zero-capacity exclusion before optimization.
- Integral min-cost-flow assignment that maximizes total score.
Expand Down Expand Up @@ -371,7 +374,11 @@ the frozen comparator's cosine, global normalization, and max/average reviewer
scoring boundary without claiming that the included synthetic vectors came
from SPECTER. See the [embedding interchange contract](docs/embedding-expertise.md).
An opt-in `--aggregation centroid` averages individually normalized local
publication vectors; it is not the frozen trainable keyphrase-centroid model.
publication vectors; it does not train keyphrase vectors. The separate
`peermatch expertise-centroid` command does train local keyphrase vectors from
caller-authored triplets and emits holdout affinities for `match-affinity`.
See the [bounded training protocol](docs/trainable-centroid.md); neither mode
claims numerical or dataset parity with the frozen OpenReview model.

To evaluate those sparse affinities against a permitted local gold standard,
use `peermatch evaluate-gold`. It supports canonical judged triples and the
Expand Down
117 changes: 117 additions & 0 deletions docs/trainable-centroid.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Bounded trainable keyphrase-centroid expertise

`peermatch expertise-centroid` is an original, dependency-free offline
training/inference workflow. It consumes a **previously generated**
[`extract-keyphrases` run](keyphrases.md), the exact document/expert source
bytes named in that run, and caller-authored train/validation triplet JSONL.
It does not download data, call a provider, run PyTorch or the frozen OpenReview
Expertise model, or reproduce its official gold-data scores.

## Synthetic end-to-end run

These files are fictional. From the repository root:

```bash
peermatch extract-keyphrases \
--documents examples/centroid/documents.json \
--experts examples/centroid/experts.json \
--directory centroid-keyphrases

peermatch expertise-centroid \
--keyphrases centroid-keyphrases \
--documents examples/centroid/documents.json \
--experts examples/centroid/experts.json \
--train examples/centroid/train.jsonl \
--validation examples/centroid/validation.jsonl \
--dimensions 2 --epochs 3 --seed 17 \
--directory centroid-run

peermatch match-affinity \
--documents centroid-run/documents.json \
--experts centroid-run/experts.json \
--conflicts centroid-run/conflicts.json \
--affinities centroid-run/affinities.csv \
--config examples/expertise/match-config.json \
--output centroid-assignment.json
```

`--conflicts` can be supplied during centroid preparation. A positive
training/validation pair that is a declared hard conflict is rejected; the
holdout conflict subset is copied to the run for assignment. Inference emits
scores for all holdout document/reviewer pairs; `match-affinity` still applies
the hard constraints. Neither a high score nor a training label overrides a
declared conflict, capacity, seniority, or institution rule.

Both output directories are create-only. Choose new names for reruns; neither
command overwrites existing files. The run contains a versioned `model.json`,
`affinities.csv`, normalized **holdout-only** `documents.json`, `experts.json`,
`conflicts.json`, and an aggregate hash/byte/count `manifest.json`. The
checkpoint stores a learned vocabulary and weights, so a real venue must
handle it as private derived data; the manifest does not include raw text or
labels. Preserve the original inputs to replay or audit the declared hashes.
These hashes detect byte changes against retained local sources; they are not
signatures or proof that caller-authored labels or evidence are trustworthy.

## Data separation and scoring

Each JSONL row is exactly:

```json
{"document_id":"d-train","positive_expert_id":"e-math","negative_expert_id":"e-graph"}
```

Train and validation must contain **disjoint submission IDs**. Repeated
triplets, contradictory labels for one submission/reviewer pair, missing
submission or reviewer keyphrases, and unknown IDs fail closed. Every source
byte stream is capped and SHA-256-bound to the keyphrase extraction manifest
or checkpoint. Vocabulary is formed only from training submissions and
reviewers occurring in training triplets. Reviewer evidence combines its
profile and publication keyphrases, deduplicating terms. Reviewer text may be
shared as *unlabeled features* across partitions; validation labels never
enter weight updates. A validation or holdout entity with no known term is
rejected rather than scored with untrained random coordinates. All
submission IDs absent from both label partitions are holdout scoring inputs.
The tool does **not** infer that this split is unbiased or temporally safe;
the caller must build partitions before examining holdout outcomes and avoid
source/author leakage across their own data.

For a document `q` and reviewer `r`, take the arithmetic mean of their
learned term vectors and score their dot product. A training triplet has
margin `q·positive - q·negative` and loss `log(1 + exp(-margin))`.
Synchronous, deterministic SGD differentiates through each centroid and
applies optional L2 weight decay to touched term vectors. The seed controls
local uniform initialization; training order is sorted by IDs. At the end of
each epoch, the untouched validation labels evaluate per-submission average
precision over *judged* positive/negative reviewers, with reviewer-ID tie
breaking. The first epoch attaining the highest validation MAP is selected;
validation never changes weights. Raw holdout dot products pass through a
logistic transform and are rounded to six decimals to satisfy the existing
`[0,1]` affinity contract. These numbers are **not calibrated probabilities**.

## Finite bounds and audit

Input files: keyphrase/document/expert sources at most 16 MiB each;
train/validation/conflicts at most 2 MiB each; keyphrase manifest 64 KiB.
At most 10,000 keyphrase records, 2,000 train and 500 validation triplets,
4,096 learned terms, 256 terms per entity, 32 dimensions, 50 epochs, and
10,000 holdout/reviewer affinities are accepted. A configured coordinate-work
bound defaults to 20 million and covers training, validation, and inference;
generated model and run byte bounds are independently enforced. Empty/missing
evidence, nonfinite coordinates/loss, invalid dimensions/IDs, forged source
hashes, or a changed input path before publication are errors.

`load_centroid_model` verifies the checkpoint's canonical payload digest and
shape; `verify_keyphrase_centroid` refits from the exact captured inputs and
compares checkpoint bytes. Training and scoring re-read the source paths and
reject replaced in-memory labels or feature mappings that disagree with the
captured bytes. The run writer performs that replay and rechecks
source paths before publishing a staged directory with no replacement. An
independent hand calculation in the tests checks one pairwise BCE gradient
step; a separate two-submission ranking oracle checks MAP. Real-world use
still requires representative labels, licensing, bias/contamination review,
held-out evaluation, declared conflicts, and human oversight.

The frozen OpenReview Expertise centroid uses a different PyTorch training,
optimizer, batcher, keyphrase and evaluation pipeline. This slice closes a
local trainable-keyphrase outcome, **not** full comparator parity, TPMS,
multifacet neural scoring, SPECTER inference, or official gold evaluation.
5 changes: 5 additions & 0 deletions examples/centroid/documents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[
{"id":"d-train","title":"Algebra matrix proof","abstract":"Linear algebra matrix theorem","required_experts":1},
{"id":"d-val","title":"Matrix algebra methods","abstract":"Linear matrix algebra","required_experts":1},
{"id":"d-hold","title":"Algebra matrix applications","abstract":"Matrix proof algebra","required_experts":1}
]
4 changes: 4 additions & 0 deletions examples/centroid/experts.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[
{"id":"e-math","name":"Math reviewer","summary":"Algebra matrix theorem","capacity":1},
{"id":"e-graph","name":"Graph reviewer","summary":"Graph networks circuits","capacity":1}
]
1 change: 1 addition & 0 deletions examples/centroid/train.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"document_id":"d-train","positive_expert_id":"e-math","negative_expert_id":"e-graph"}
1 change: 1 addition & 0 deletions examples/centroid/validation.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"document_id":"d-val","positive_expert_id":"e-math","negative_expert_id":"e-graph"}
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "peermatchlab"
version = "0.15.0"
version = "0.16.0"
description = "Transparent, constraint-aware expert matching for offline workflows"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
24 changes: 23 additions & 1 deletion src/peermatchlab/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@
local_domain_inputs,
write_expertise_run,
)
from peermatchlab.keyphrase_centroid import (
CentroidConfig,
CentroidInputs,
CentroidModel,
CentroidTriplet,
load_centroid_inputs,
load_centroid_model,
score_keyphrase_centroid,
train_keyphrase_centroid,
verify_keyphrase_centroid,
write_keyphrase_centroid_run,
)
from peermatchlab.keyphrases import (
Keyphrase,
KeyphraseConfig,
Expand Down Expand Up @@ -98,6 +110,10 @@
"AssignmentEngine",
"AssignmentStrategy",
"AssignmentWriteTransport",
"CentroidConfig",
"CentroidInputs",
"CentroidModel",
"CentroidTriplet",
"DemandDiagnostic",
"Document",
"EmbeddingProvider",
Expand Down Expand Up @@ -149,6 +165,8 @@
"fetch_reviewer_expertise",
"generate_expertise",
"load_affinities_csv",
"load_centroid_inputs",
"load_centroid_model",
"load_expertise_config_source",
"load_local_domain_expertise_inputs",
"load_openreview_expertise_snapshot",
Expand All @@ -163,11 +181,15 @@
"request_sha256",
"reviewer_ids_to_experts",
"score_embedding_expertise",
"score_keyphrase_centroid",
"train_keyphrase_centroid",
"verify_keyphrase_centroid",
"write_embedding_run",
"write_expertise_run",
"write_keyphrase_centroid_run",
"write_keyphrase_run",
"write_openreview_snapshot",
"write_publish_artifact",
]

__version__ = "0.15.0"
__version__ = "0.16.0"
48 changes: 48 additions & 0 deletions src/peermatchlab/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@
plan_to_dict,
write_json,
)
from peermatchlab.keyphrase_centroid import (
CentroidConfig,
load_centroid_inputs,
train_keyphrase_centroid,
write_keyphrase_centroid_run,
)
from peermatchlab.keyphrases import KeyphraseConfig, read_keyphrase_source, write_keyphrase_run
from peermatchlab.models import Conflict, DataValidationError, Document, Expert
from peermatchlab.openreview import load_openreview_submissions, load_reviewer_ids
Expand Down Expand Up @@ -228,6 +234,22 @@ def build_parser() -> argparse.ArgumentParser:
keyphrases.add_argument("--window-size", type=int, default=2)
keyphrases.add_argument("--iterations", type=int, default=30)
keyphrases.add_argument("--damping", type=float, default=0.85)
centroid = commands.add_parser(
"expertise-centroid", help="train and score a bounded local keyphrase centroid"
)
centroid.add_argument("--keyphrases", required=True, help="extract-keyphrases run directory")
centroid.add_argument("--documents", required=True, help="same exact document source bytes")
centroid.add_argument("--experts", required=True, help="same exact expert source bytes")
centroid.add_argument("--train", required=True, help="train triplets JSONL")
centroid.add_argument("--validation", required=True, help="disjoint validation triplets JSONL")
centroid.add_argument("--conflicts", help="optional hard-conflict JSON/JSONL")
centroid.add_argument("--dimensions", type=int, default=8)
centroid.add_argument("--epochs", type=int, default=8)
centroid.add_argument("--learning-rate", type=float, default=0.1)
centroid.add_argument("--l2", type=float, default=0.0)
centroid.add_argument("--seed", type=int, default=17)
centroid.add_argument("--max-work", type=int, default=20_000_000)
centroid.add_argument("--directory", required=True, help="new match-ready artifact directory")
return parser


Expand Down Expand Up @@ -609,6 +631,32 @@ def main(argv: Sequence[str] | None = None) -> int:
)
print(f"extracted {manifest['records']} evidence records to {args.directory}")
return 0
if args.command == "expertise-centroid":
inputs = load_centroid_inputs(
args.keyphrases,
args.documents,
args.experts,
args.train,
args.validation,
conflicts_path=args.conflicts,
)
model = train_keyphrase_centroid(
inputs,
config=CentroidConfig(
dimensions=args.dimensions,
epochs=args.epochs,
learning_rate=args.learning_rate,
l2=args.l2,
seed=args.seed,
max_work=args.max_work,
),
)
manifest = write_keyphrase_centroid_run(inputs, model, args.directory)
counts = manifest["counts"]
if not isinstance(counts, Mapping):
raise DataValidationError("centroid manifest counts are invalid")
print(f"generated {counts['affinities']} holdout affinities to {args.directory}")
return 0
if args.command == "evaluate-gold":
eval_report = evaluate_gold_files(
args.gold,
Expand Down
Loading
Loading