Spaces:
Sleeping
Sleeping
Add matchmaker 1B training pipeline
Browse files- .gitignore +10 -0
- README.md +18 -3
- training/README.md +130 -0
- training/config/matchmaker_1b_lora.yaml +41 -0
- training/evaluate_matchmaker.py +93 -0
- training/examples/synthetic_profiles.jsonl +3 -0
- training/predict.py +59 -0
- training/prepare_dataset.py +215 -0
- training/requirements-training.txt +10 -0
- training/src/lovegpt_matchmaker_training/__init__.py +5 -0
- training/src/lovegpt_matchmaker_training/crypto.py +68 -0
- training/src/lovegpt_matchmaker_training/features.py +85 -0
- training/src/lovegpt_matchmaker_training/privacy.py +49 -0
- training/src/lovegpt_matchmaker_training/questions.py +27 -0
- training/src/lovegpt_matchmaker_training/schema.py +26 -0
- training/src/lovegpt_matchmaker_training/teacher.py +107 -0
- training/train_lora.py +127 -0
.gitignore
CHANGED
|
@@ -17,6 +17,11 @@ yarn-debug.log*
|
|
| 17 |
yarn-error.log*
|
| 18 |
pnpm-debug.log*
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
# Backend build
|
| 21 |
backend/dist/
|
| 22 |
|
|
@@ -40,3 +45,8 @@ frontend/ios/Flutter/Flutter.podspec
|
|
| 40 |
# Supabase
|
| 41 |
.branches/
|
| 42 |
.temp/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
yarn-error.log*
|
| 18 |
pnpm-debug.log*
|
| 19 |
|
| 20 |
+
# Python
|
| 21 |
+
__pycache__/
|
| 22 |
+
**/__pycache__/
|
| 23 |
+
*.pyc
|
| 24 |
+
|
| 25 |
# Backend build
|
| 26 |
backend/dist/
|
| 27 |
|
|
|
|
| 45 |
# Supabase
|
| 46 |
.branches/
|
| 47 |
.temp/
|
| 48 |
+
|
| 49 |
+
# Training artifacts may contain profile-derived data
|
| 50 |
+
training/outputs/
|
| 51 |
+
training/.cache/
|
| 52 |
+
wandb/
|
README.md
CHANGED
|
@@ -21,8 +21,9 @@ Premium AI dating platform powered by OpenClaw.
|
|
| 21 |
- `backend/supabase/migrations/` PostgreSQL schema, RLS, pgvector matching RPC.
|
| 22 |
- `shared/` full compatibility questionnaire and shared TypeScript contracts.
|
| 23 |
- `artifacts/` OpenClaw prompt and response schema.
|
| 24 |
-
- `docs/` setup, API, and deployment notes.
|
| 25 |
-
- `app.py` Gradio/Hugging Face Space adapter.
|
|
|
|
| 26 |
|
| 27 |
## Gradio Space
|
| 28 |
|
|
@@ -58,7 +59,21 @@ $env:DATACLAW_ALLOW_PROFILE_DECRYPT="1"
|
|
| 58 |
python tools/dataclaw_profile_vault.py encrypted-profile.jsonl.enc --output decrypted.jsonl --i-understand-raw-profile-output
|
| 59 |
```
|
| 60 |
|
| 61 |
-
Do not expose decrypted JSONL through the Space UI or logs.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
## Backend
|
| 64 |
|
|
|
|
| 21 |
- `backend/supabase/migrations/` PostgreSQL schema, RLS, pgvector matching RPC.
|
| 22 |
- `shared/` full compatibility questionnaire and shared TypeScript contracts.
|
| 23 |
- `artifacts/` OpenClaw prompt and response schema.
|
| 24 |
+
- `docs/` setup, API, and deployment notes.
|
| 25 |
+
- `app.py` Gradio/Hugging Face Space adapter.
|
| 26 |
+
- `training/` privacy-preserving matchmaker model training pipeline.
|
| 27 |
|
| 28 |
## Gradio Space
|
| 29 |
|
|
|
|
| 59 |
python tools/dataclaw_profile_vault.py encrypted-profile.jsonl.enc --output decrypted.jsonl --i-understand-raw-profile-output
|
| 60 |
```
|
| 61 |
|
| 62 |
+
Do not expose decrypted JSONL through the Space UI or logs.
|
| 63 |
+
|
| 64 |
+
## Matchmaker Training
|
| 65 |
+
|
| 66 |
+
The training pipeline builds de-identified pairwise examples from the encrypted matchmaker table and trains a small LoRA matchmaker model.
|
| 67 |
+
|
| 68 |
+
```powershell
|
| 69 |
+
$env:MATCHMAKER_TRAINING_ACK="1"
|
| 70 |
+
python training/prepare_dataset.py `
|
| 71 |
+
--input training/examples/synthetic_profiles.jsonl `
|
| 72 |
+
--input-format plain-profiles `
|
| 73 |
+
--out-dir training/outputs/datasets/synthetic
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
See `training/README.md` for real Dataclaw table prep, 1B LoRA training, prediction, and evaluation.
|
| 77 |
|
| 78 |
## Backend
|
| 79 |
|
training/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# loveGPT Matchmaker 1B Training Pipeline
|
| 2 |
+
|
| 3 |
+
This pipeline trains a small instruction model to score, weight, and compare de-identified dating profiles for the loveGPT matchmaker.
|
| 4 |
+
|
| 5 |
+
The model is not trained to reveal people. It is trained to return a structured compatibility judgment from two profile views:
|
| 6 |
+
|
| 7 |
+
```json
|
| 8 |
+
{
|
| 9 |
+
"compatibility": 72.4,
|
| 10 |
+
"label": "candidate",
|
| 11 |
+
"invite_to_speed_date": true,
|
| 12 |
+
"dimensions": [
|
| 13 |
+
{"id": "values_future", "score": 81.0, "weight": 0.18}
|
| 14 |
+
],
|
| 15 |
+
"rationale": ["Strongest signal: Values and future direction at 81.0%."]
|
| 16 |
+
}
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
## Privacy Rules
|
| 20 |
+
|
| 21 |
+
- The Space UI never exposes raw JSONL.
|
| 22 |
+
- Dataset preparation requires `MATCHMAKER_TRAINING_ACK=1`.
|
| 23 |
+
- Encrypted matchmaker rows are decrypted only by the training prep script and immediately converted into a de-identified training view.
|
| 24 |
+
- Training examples remove `userId`, `username`, `profileId`, display name, exact age, exact location, contact details, URLs, emails, phone numbers, and handles.
|
| 25 |
+
- Do not commit `training/outputs/`; it may contain sensitive profile-derived text.
|
| 26 |
+
|
| 27 |
+
## Base Model
|
| 28 |
+
|
| 29 |
+
Default: `meta-llama/Llama-3.2-1B-Instruct`
|
| 30 |
+
|
| 31 |
+
Good alternatives:
|
| 32 |
+
|
| 33 |
+
- `TinyLlama/TinyLlama-1.1B-Chat-v1.0` for very low-resource experiments.
|
| 34 |
+
- `HuggingFaceTB/SmolLM2-1.7B-Instruct` if a slightly larger small model is acceptable.
|
| 35 |
+
- `Qwen/Qwen2.5-1.5B-Instruct` if you want stronger JSON behavior and are okay using Qwen-family models.
|
| 36 |
+
|
| 37 |
+
## Install
|
| 38 |
+
|
| 39 |
+
```powershell
|
| 40 |
+
python -m pip install -r training/requirements-training.txt
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
For QLoRA on Windows, `bitsandbytes` may not be available. Train on Linux/RunPod/Hugging Face GPU for the normal 4-bit path.
|
| 44 |
+
|
| 45 |
+
## Smoke Test With Synthetic Profiles
|
| 46 |
+
|
| 47 |
+
```powershell
|
| 48 |
+
$env:MATCHMAKER_TRAINING_ACK="1"
|
| 49 |
+
python training/prepare_dataset.py `
|
| 50 |
+
--input training/examples/synthetic_profiles.jsonl `
|
| 51 |
+
--input-format plain-profiles `
|
| 52 |
+
--out-dir training/outputs/datasets/synthetic
|
| 53 |
+
|
| 54 |
+
python training/evaluate_matchmaker.py `
|
| 55 |
+
--dataset training/outputs/datasets/synthetic/eval.jsonl
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
## Prepare Real Dataclaw Data
|
| 59 |
+
|
| 60 |
+
Use the encrypted matchmaker table, not raw user exports.
|
| 61 |
+
|
| 62 |
+
```powershell
|
| 63 |
+
$env:MATCHMAKER_TABLE_KEY="your-space-secret"
|
| 64 |
+
$env:MATCHMAKER_TRAINING_ACK="1"
|
| 65 |
+
|
| 66 |
+
python training/prepare_dataset.py `
|
| 67 |
+
--input C:\path\to\lovegpt-matchmaker-table.jsonl.enc `
|
| 68 |
+
--input-format encrypted-matchmaker `
|
| 69 |
+
--out-dir training/outputs/datasets/matchmaker `
|
| 70 |
+
--max-pairs 50000
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
If `MATCHMAKER_TABLE_KEY` is not set, the prep script can derive the table key from `DATACLAW_PROFILE_KEY`, matching the Space behavior.
|
| 74 |
+
|
| 75 |
+
Optional feedback override file:
|
| 76 |
+
|
| 77 |
+
```json
|
| 78 |
+
{"profileAId":"profile-a","profileBId":"profile-b","compatibility":88,"label":"high_match","invite_to_speed_date":true,"rationale":["Both users opted into contact exchange after the speed-date."]}
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
Use it like this:
|
| 82 |
+
|
| 83 |
+
```powershell
|
| 84 |
+
python training/prepare_dataset.py `
|
| 85 |
+
--input C:\path\to\lovegpt-matchmaker-table.jsonl.enc `
|
| 86 |
+
--input-format encrypted-matchmaker `
|
| 87 |
+
--feedback C:\path\to\matchmaker-feedback.jsonl `
|
| 88 |
+
--out-dir training/outputs/datasets/matchmaker
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
## Train LoRA
|
| 92 |
+
|
| 93 |
+
```powershell
|
| 94 |
+
python training/train_lora.py `
|
| 95 |
+
--config training/config/matchmaker_1b_lora.yaml
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
Output adapter:
|
| 99 |
+
|
| 100 |
+
```text
|
| 101 |
+
training/outputs/adapters/matchmaker-1b-lora
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
## Predict And Evaluate
|
| 105 |
+
|
| 106 |
+
```powershell
|
| 107 |
+
python training/predict.py `
|
| 108 |
+
--model meta-llama/Llama-3.2-1B-Instruct `
|
| 109 |
+
--adapter training/outputs/adapters/matchmaker-1b-lora `
|
| 110 |
+
--input training/outputs/datasets/matchmaker/test.jsonl `
|
| 111 |
+
--output training/outputs/predictions/matchmaker-test.jsonl
|
| 112 |
+
|
| 113 |
+
python training/evaluate_matchmaker.py `
|
| 114 |
+
--dataset training/outputs/datasets/matchmaker/test.jsonl `
|
| 115 |
+
--predictions training/outputs/predictions/matchmaker-test.jsonl
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
Metrics:
|
| 119 |
+
|
| 120 |
+
- `validJsonRate`: model follows the strict output contract.
|
| 121 |
+
- `compatibilityMae`: average absolute error against the teacher/human target score.
|
| 122 |
+
- `inviteAccuracy`: whether the model agrees on opening a speed-date.
|
| 123 |
+
|
| 124 |
+
## Improvement Loop
|
| 125 |
+
|
| 126 |
+
1. Start with `strict-v1` teacher labels from the current matchmaker harness.
|
| 127 |
+
2. Add human feedback later from mutual exchange outcomes, post-chat ratings, safety reports, and blocks.
|
| 128 |
+
3. Weight feedback labels above teacher labels once enough data exists.
|
| 129 |
+
4. Keep the deterministic harness as a fallback and safety gate.
|
| 130 |
+
5. Only let the trained model propose scores; final room creation should still require hard privacy/safety checks.
|
training/config/matchmaker_1b_lora.yaml
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
model:
|
| 2 |
+
base_model: meta-llama/Llama-3.2-1B-Instruct
|
| 3 |
+
trust_remote_code: false
|
| 4 |
+
load_in_4bit: true
|
| 5 |
+
device_map: auto
|
| 6 |
+
|
| 7 |
+
lora:
|
| 8 |
+
r: 32
|
| 9 |
+
alpha: 64
|
| 10 |
+
dropout: 0.05
|
| 11 |
+
target_modules:
|
| 12 |
+
- q_proj
|
| 13 |
+
- k_proj
|
| 14 |
+
- v_proj
|
| 15 |
+
- o_proj
|
| 16 |
+
- gate_proj
|
| 17 |
+
- up_proj
|
| 18 |
+
- down_proj
|
| 19 |
+
|
| 20 |
+
data:
|
| 21 |
+
train_file: training/outputs/datasets/matchmaker/train.jsonl
|
| 22 |
+
eval_file: training/outputs/datasets/matchmaker/eval.jsonl
|
| 23 |
+
|
| 24 |
+
training:
|
| 25 |
+
output_dir: training/outputs/runs/matchmaker-1b-lora
|
| 26 |
+
adapter_output_dir: training/outputs/adapters/matchmaker-1b-lora
|
| 27 |
+
max_seq_length: 4096
|
| 28 |
+
per_device_train_batch_size: 1
|
| 29 |
+
per_device_eval_batch_size: 1
|
| 30 |
+
gradient_accumulation_steps: 16
|
| 31 |
+
learning_rate: 0.00015
|
| 32 |
+
num_train_epochs: 2
|
| 33 |
+
warmup_ratio: 0.05
|
| 34 |
+
logging_steps: 10
|
| 35 |
+
eval_steps: 100
|
| 36 |
+
save_steps: 100
|
| 37 |
+
save_total_limit: 2
|
| 38 |
+
bf16: true
|
| 39 |
+
fp16: false
|
| 40 |
+
packing: false
|
| 41 |
+
report_to: []
|
training/evaluate_matchmaker.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import math
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from statistics import mean
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def load_jsonl(path: Path) -> list[dict[str, Any]]:
|
| 12 |
+
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def parse_prediction(row: dict[str, Any]) -> dict[str, Any] | None:
|
| 16 |
+
text = row.get("predictionText", "")
|
| 17 |
+
if isinstance(text, dict):
|
| 18 |
+
return text
|
| 19 |
+
start = str(text).find("{")
|
| 20 |
+
end = str(text).rfind("}")
|
| 21 |
+
if start < 0 or end <= start:
|
| 22 |
+
return None
|
| 23 |
+
try:
|
| 24 |
+
parsed = json.loads(str(text)[start : end + 1])
|
| 25 |
+
except json.JSONDecodeError:
|
| 26 |
+
return None
|
| 27 |
+
return parsed if isinstance(parsed, dict) else None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def invite(value: dict[str, Any]) -> bool:
|
| 31 |
+
return bool(value.get("invite_to_speed_date") or value.get("passed"))
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def main() -> None:
|
| 35 |
+
parser = argparse.ArgumentParser(description="Evaluate matchmaker predictions against teacher or feedback labels.")
|
| 36 |
+
parser.add_argument("--dataset", type=Path, required=True, help="Test/eval JSONL produced by prepare_dataset.py.")
|
| 37 |
+
parser.add_argument("--predictions", type=Path, help="Prediction JSONL produced by predict.py.")
|
| 38 |
+
args = parser.parse_args()
|
| 39 |
+
|
| 40 |
+
dataset = {row["pairHash"]: row for row in load_jsonl(args.dataset)}
|
| 41 |
+
if not args.predictions:
|
| 42 |
+
scores = [float(row["target"]["compatibility"]) for row in dataset.values()]
|
| 43 |
+
labels = {row["target"]["label"] for row in dataset.values()}
|
| 44 |
+
print(
|
| 45 |
+
json.dumps(
|
| 46 |
+
{
|
| 47 |
+
"pairs": len(dataset),
|
| 48 |
+
"meanTeacherCompatibility": round(mean(scores), 3) if scores else 0,
|
| 49 |
+
"positiveRate": round(sum(invite(row["target"]) for row in dataset.values()) / max(1, len(dataset)), 4),
|
| 50 |
+
"labels": sorted(labels),
|
| 51 |
+
},
|
| 52 |
+
indent=2,
|
| 53 |
+
sort_keys=True,
|
| 54 |
+
)
|
| 55 |
+
)
|
| 56 |
+
return
|
| 57 |
+
|
| 58 |
+
pred_rows = load_jsonl(args.predictions)
|
| 59 |
+
mae_values: list[float] = []
|
| 60 |
+
exact_invite = 0
|
| 61 |
+
valid_json = 0
|
| 62 |
+
evaluated = 0
|
| 63 |
+
for pred_row in pred_rows:
|
| 64 |
+
target_row = dataset.get(pred_row["pairHash"])
|
| 65 |
+
if not target_row:
|
| 66 |
+
continue
|
| 67 |
+
evaluated += 1
|
| 68 |
+
parsed = parse_prediction(pred_row)
|
| 69 |
+
if not parsed:
|
| 70 |
+
continue
|
| 71 |
+
valid_json += 1
|
| 72 |
+
target_score = float(target_row["target"]["compatibility"])
|
| 73 |
+
pred_score = float(parsed.get("compatibility", math.nan))
|
| 74 |
+
if not math.isnan(pred_score):
|
| 75 |
+
mae_values.append(abs(pred_score - target_score))
|
| 76 |
+
exact_invite += int(invite(parsed) == invite(target_row["target"]))
|
| 77 |
+
|
| 78 |
+
print(
|
| 79 |
+
json.dumps(
|
| 80 |
+
{
|
| 81 |
+
"evaluated": evaluated,
|
| 82 |
+
"validJsonRate": round(valid_json / max(1, evaluated), 4),
|
| 83 |
+
"compatibilityMae": round(mean(mae_values), 3) if mae_values else None,
|
| 84 |
+
"inviteAccuracy": round(exact_invite / max(1, evaluated), 4),
|
| 85 |
+
},
|
| 86 |
+
indent=2,
|
| 87 |
+
sort_keys=True,
|
| 88 |
+
)
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
if __name__ == "__main__":
|
| 93 |
+
main()
|
training/examples/synthetic_profiles.jsonl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{"source":"synthetic","profileId":"synthetic-a","profileComplete":true,"profile":{"display_name":"Synthetic A","age":"31","location":"Hidden","intent":"Long-term relationship"},"answers":{"q1":{"answer":"Honesty, warmth, loyalty, emotional courage, and building a thoughtful life through daily follow-through.","dealbreakerSeverity":null},"q2":{"answer":"A calm home, meaningful work, shared rituals, and enough ambition to keep growing without burning out.","dealbreakerSeverity":null},"q6":{"answer":"I bond through consistency, direct reassurance, and quiet dependability.","dealbreakerSeverity":null},"q11":{"answer":"I am captivated by kindness with backbone, dry wit, competence, and emotional steadiness.","dealbreakerSeverity":null},"q16":{"answer":"Poor hygiene, contempt toward service workers, and cruelty presented as humor are hard no signals.","dealbreakerSeverity":"high"},"q23":{"answer":"Conflict needs accountability, repair, and no disappearing for days.","dealbreakerSeverity":null},"q27":{"answer":"I prefer tidy spaces, planned weekends, health routines, and a grounded social life.","dealbreakerSeverity":null},"q37":{"answer":"Average build, active, comfortable with a soft but healthy body.","dealbreakerSeverity":null},"q38":{"answer":"I am attracted to average to athletic builds and am flexible when warmth and chemistry are there.","dealbreakerSeverity":null},"q39":{"answer":"Flexibility matters more than exact body type; hygiene and confidence carry a lot.","dealbreakerSeverity":null},"q40":{"answer":"I want my body to be seen kindly and realistically, not inspected like a checklist.","dealbreakerSeverity":null}},"captivatingTraits":["kindness with backbone","dry wit","competence"],"disgustTriggers":["poor hygiene","cruelty","contempt"]}
|
| 2 |
+
{"source":"synthetic","profileId":"synthetic-b","profileComplete":true,"profile":{"display_name":"Synthetic B","age":"33","location":"Hidden","intent":"Life partner"},"answers":{"q1":{"answer":"Kindness, honesty, accountability, curiosity, and a peaceful home life are non-negotiable.","dealbreakerSeverity":null},"q2":{"answer":"I want a warm home, good work, shared meals, travel sometimes, and a partner who repairs conflict quickly.","dealbreakerSeverity":null},"q6":{"answer":"I feel safest with steady communication and someone who means what they say.","dealbreakerSeverity":null},"q11":{"answer":"I admire emotionally mature people who are funny, grounded, capable, and affectionate.","dealbreakerSeverity":null},"q16":{"answer":"Cruel jokes, dirty living spaces, and chronic disrespect make attraction disappear.","dealbreakerSeverity":"high"},"q23":{"answer":"I can handle hard conversations if there is no stonewalling and both people apologize clearly.","dealbreakerSeverity":null},"q27":{"answer":"A clean home, moderate social life, exercise, and financial responsibility fit me best.","dealbreakerSeverity":null},"q37":{"answer":"Average active body type, not extremely lean, comfortable and healthy.","dealbreakerSeverity":null},"q38":{"answer":"I like average, soft, or athletic bodies and care more about energy and hygiene than a strict type.","dealbreakerSeverity":null},"q39":{"answer":"Very flexible if the person is confident, kind, and physically affectionate.","dealbreakerSeverity":null},"q40":{"answer":"I want attraction to feel mutual and generous.","dealbreakerSeverity":null}},"captivatingTraits":["emotionally mature","funny","grounded"],"disgustTriggers":["dirty living spaces","cruel jokes","disrespect"]}
|
| 3 |
+
{"source":"synthetic","profileId":"synthetic-c","profileComplete":true,"profile":{"display_name":"Synthetic C","age":"29","location":"Hidden","intent":"Intentional dating"},"answers":{"q1":{"answer":"Freedom, novelty, ambition, intensity, independence, and social momentum matter most.","dealbreakerSeverity":null},"q2":{"answer":"I want a highly mobile life with spontaneous travel, big career swings, and no fixed domestic routine.","dealbreakerSeverity":null},"q6":{"answer":"I need a lot of space and dislike frequent reassurance conversations.","dealbreakerSeverity":null},"q11":{"answer":"I am drawn to status, edge, boldness, nightlife charisma, and high intensity chemistry.","dealbreakerSeverity":null},"q16":{"answer":"Clinginess, routine, domestic predictability, and cautious people turn me off.","dealbreakerSeverity":"medium"},"q23":{"answer":"I prefer to cool off alone and revisit conflict later if it still matters.","dealbreakerSeverity":null},"q27":{"answer":"Late nights, travel, networking, and loose plans suit me best.","dealbreakerSeverity":null},"q37":{"answer":"Lean athletic body and high energy lifestyle.","dealbreakerSeverity":null},"q38":{"answer":"I strongly prefer very fit, polished, high-energy partners.","dealbreakerSeverity":null},"q39":{"answer":"I am not very flexible on physical type because attraction is immediate for me.","dealbreakerSeverity":null},"q40":{"answer":"I want a partner to appreciate discipline and presentation.","dealbreakerSeverity":null}},"captivatingTraits":["status","edge","boldness"],"disgustTriggers":["clinginess","routine","cautious people"]}
|
training/predict.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def load_jsonl(path: Path) -> list[dict]:
|
| 9 |
+
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def main() -> None:
|
| 13 |
+
parser = argparse.ArgumentParser(description="Generate matchmaker predictions from a trained adapter.")
|
| 14 |
+
parser.add_argument("--model", required=True, help="Base model id or merged model path.")
|
| 15 |
+
parser.add_argument("--adapter", default="", help="Optional PEFT adapter path.")
|
| 16 |
+
parser.add_argument("--input", type=Path, required=True)
|
| 17 |
+
parser.add_argument("--output", type=Path, required=True)
|
| 18 |
+
parser.add_argument("--max-new-tokens", type=int, default=512)
|
| 19 |
+
args = parser.parse_args()
|
| 20 |
+
|
| 21 |
+
import torch
|
| 22 |
+
from peft import PeftModel
|
| 23 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 24 |
+
|
| 25 |
+
tokenizer = AutoTokenizer.from_pretrained(args.adapter or args.model, trust_remote_code=True)
|
| 26 |
+
if tokenizer.pad_token is None:
|
| 27 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 28 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 29 |
+
args.model,
|
| 30 |
+
device_map="auto",
|
| 31 |
+
torch_dtype=torch.bfloat16 if torch.cuda.is_available() else torch.float32,
|
| 32 |
+
trust_remote_code=True,
|
| 33 |
+
)
|
| 34 |
+
if args.adapter:
|
| 35 |
+
model = PeftModel.from_pretrained(model, args.adapter)
|
| 36 |
+
model.eval()
|
| 37 |
+
|
| 38 |
+
rows = load_jsonl(args.input)
|
| 39 |
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
| 40 |
+
with args.output.open("w", encoding="utf-8") as handle:
|
| 41 |
+
for row in rows:
|
| 42 |
+
messages = row["messages"][:2]
|
| 43 |
+
prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) if getattr(tokenizer, "chat_template", None) else "\n\n".join(f"{m['role'].upper()}: {m['content']}" for m in messages)
|
| 44 |
+
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
|
| 45 |
+
with torch.no_grad():
|
| 46 |
+
output = model.generate(
|
| 47 |
+
**inputs,
|
| 48 |
+
max_new_tokens=args.max_new_tokens,
|
| 49 |
+
temperature=0.0,
|
| 50 |
+
do_sample=False,
|
| 51 |
+
pad_token_id=tokenizer.eos_token_id,
|
| 52 |
+
)
|
| 53 |
+
text = tokenizer.decode(output[0][inputs["input_ids"].shape[1] :], skip_special_tokens=True)
|
| 54 |
+
handle.write(json.dumps({"pairHash": row["pairHash"], "target": row["target"], "predictionText": text}, ensure_ascii=False, sort_keys=True))
|
| 55 |
+
handle.write("\n")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
if __name__ == "__main__":
|
| 59 |
+
main()
|
training/prepare_dataset.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import itertools
|
| 5 |
+
import json
|
| 6 |
+
import random
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 12 |
+
sys.path.insert(0, str(ROOT / "training" / "src"))
|
| 13 |
+
|
| 14 |
+
from lovegpt_matchmaker_training.crypto import decrypt_matchmaker_row
|
| 15 |
+
from lovegpt_matchmaker_training.features import compact_profile_for_prompt, sanitize_profile
|
| 16 |
+
from lovegpt_matchmaker_training.privacy import require_training_ack, stable_hash
|
| 17 |
+
from lovegpt_matchmaker_training.questions import load_questions
|
| 18 |
+
from lovegpt_matchmaker_training.teacher import label_from_score, strict_matchmaker_judgment
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
SYSTEM_PROMPT = """You are the loveGPT matchmaker scoring model.
|
| 22 |
+
Compare two de-identified dating profiles. Measure long-term values, emotional safety,
|
| 23 |
+
captivating traits, disgust filters, conflict repair, lifestyle fit, and body-attraction alignment.
|
| 24 |
+
Return one valid JSON object only. Never infer or reveal private identity details."""
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def load_plain_profiles(path: Path) -> list[dict[str, Any]]:
|
| 28 |
+
profiles: list[dict[str, Any]] = []
|
| 29 |
+
for line in path.read_text(encoding="utf-8").splitlines():
|
| 30 |
+
if not line.strip():
|
| 31 |
+
continue
|
| 32 |
+
profiles.append(json.loads(line))
|
| 33 |
+
return profiles
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def load_encrypted_matchmaker_profiles(path: Path) -> list[dict[str, Any]]:
|
| 37 |
+
latest: dict[str, dict[str, Any]] = {}
|
| 38 |
+
for line in path.read_text(encoding="utf-8").splitlines():
|
| 39 |
+
if not line.strip():
|
| 40 |
+
continue
|
| 41 |
+
payload = decrypt_matchmaker_row(line)
|
| 42 |
+
profile_id = str(payload.get("profileId") or "")
|
| 43 |
+
if not profile_id:
|
| 44 |
+
continue
|
| 45 |
+
previous = latest.get(profile_id)
|
| 46 |
+
if previous is None or str(payload.get("loggedAt", "")) >= str(previous.get("loggedAt", "")):
|
| 47 |
+
latest[profile_id] = payload
|
| 48 |
+
return list(latest.values())
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def anonymized_profile_key(payload: dict[str, Any]) -> str:
|
| 52 |
+
raw = "::".join(
|
| 53 |
+
[
|
| 54 |
+
str(payload.get("profileId") or ""),
|
| 55 |
+
str(payload.get("userId") or ""),
|
| 56 |
+
json.dumps(payload.get("answers", {}), sort_keys=True, ensure_ascii=False)[:2048],
|
| 57 |
+
]
|
| 58 |
+
)
|
| 59 |
+
return stable_hash(raw)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def load_feedback(path: Path | None, profiles: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
| 63 |
+
if not path:
|
| 64 |
+
return {}
|
| 65 |
+
by_profile_id = {str(profile.get("profileId") or ""): anonymized_profile_key(profile) for profile in profiles}
|
| 66 |
+
feedback: dict[str, dict[str, Any]] = {}
|
| 67 |
+
for line in path.read_text(encoding="utf-8").splitlines():
|
| 68 |
+
if not line.strip():
|
| 69 |
+
continue
|
| 70 |
+
row = json.loads(line)
|
| 71 |
+
pair_hash = row.get("pairHash")
|
| 72 |
+
if not pair_hash:
|
| 73 |
+
profile_a_id = str(row.get("profileAId") or "")
|
| 74 |
+
profile_b_id = str(row.get("profileBId") or "")
|
| 75 |
+
if profile_a_id not in by_profile_id or profile_b_id not in by_profile_id:
|
| 76 |
+
continue
|
| 77 |
+
pair_hash = stable_hash("::".join(sorted([by_profile_id[profile_a_id], by_profile_id[profile_b_id]])), salt="lovegpt-pair")
|
| 78 |
+
target: dict[str, Any] = {}
|
| 79 |
+
if "compatibility" in row:
|
| 80 |
+
target["compatibility"] = round(float(row["compatibility"]), 1)
|
| 81 |
+
if "label" in row:
|
| 82 |
+
target["label"] = str(row["label"])
|
| 83 |
+
if "invite_to_speed_date" in row:
|
| 84 |
+
target["invite_to_speed_date"] = bool(row["invite_to_speed_date"])
|
| 85 |
+
if "dimensions" in row and isinstance(row["dimensions"], list):
|
| 86 |
+
target["dimensions"] = row["dimensions"]
|
| 87 |
+
if "rationale" in row and isinstance(row["rationale"], list):
|
| 88 |
+
target["rationale"] = [str(item)[:240] for item in row["rationale"][:6]]
|
| 89 |
+
if target:
|
| 90 |
+
feedback[str(pair_hash)] = target
|
| 91 |
+
return feedback
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def build_pair_example(profile_a: dict[str, Any], profile_b: dict[str, Any], split: str, feedback: dict[str, Any] | None = None) -> dict[str, Any]:
|
| 95 |
+
_, questions = load_questions()
|
| 96 |
+
safe_a = sanitize_profile(profile_a, questions)
|
| 97 |
+
safe_b = sanitize_profile(profile_b, questions)
|
| 98 |
+
judgment = strict_matchmaker_judgment(safe_a, safe_b)
|
| 99 |
+
score = float(judgment["compatibility"])
|
| 100 |
+
output = {
|
| 101 |
+
"compatibility": score,
|
| 102 |
+
"label": label_from_score(score),
|
| 103 |
+
"invite_to_speed_date": bool(judgment["passed"]),
|
| 104 |
+
"dimensions": judgment["dimensions"],
|
| 105 |
+
"rationale": dimension_rationale(judgment),
|
| 106 |
+
}
|
| 107 |
+
if feedback:
|
| 108 |
+
output = {
|
| 109 |
+
**output,
|
| 110 |
+
**feedback,
|
| 111 |
+
"compatibility": float(feedback.get("compatibility", output["compatibility"])),
|
| 112 |
+
"label": str(feedback.get("label", output["label"])),
|
| 113 |
+
"invite_to_speed_date": bool(feedback.get("invite_to_speed_date", output["invite_to_speed_date"])),
|
| 114 |
+
"dimensions": feedback.get("dimensions", output["dimensions"]),
|
| 115 |
+
"rationale": feedback.get("rationale", output["rationale"]),
|
| 116 |
+
}
|
| 117 |
+
user_payload = {
|
| 118 |
+
"profileA": compact_profile_for_prompt(safe_a),
|
| 119 |
+
"profileB": compact_profile_for_prompt(safe_b),
|
| 120 |
+
"task": "Score whether these two de-identified dating profiles should be introduced for a 20-minute speed-date.",
|
| 121 |
+
}
|
| 122 |
+
pair_id = "::".join(sorted([anonymized_profile_key(profile_a), anonymized_profile_key(profile_b)]))
|
| 123 |
+
return {
|
| 124 |
+
"schema": "lovegpt.matchmaker.sft_pair.v1",
|
| 125 |
+
"pairHash": stable_hash(pair_id, salt="lovegpt-pair"),
|
| 126 |
+
"split": split,
|
| 127 |
+
"targetSource": "feedback-v1" if feedback else "strict-v1",
|
| 128 |
+
"messages": [
|
| 129 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 130 |
+
{"role": "user", "content": json.dumps(user_payload, ensure_ascii=False, sort_keys=True)},
|
| 131 |
+
{"role": "assistant", "content": json.dumps(output, ensure_ascii=False, sort_keys=True)},
|
| 132 |
+
],
|
| 133 |
+
"target": output,
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def dimension_rationale(judgment: dict[str, Any]) -> list[str]:
|
| 138 |
+
ordered = sorted(judgment["dimensions"], key=lambda item: item["score"])
|
| 139 |
+
weakest = ordered[:2]
|
| 140 |
+
strongest = ordered[-2:]
|
| 141 |
+
notes = [
|
| 142 |
+
f"Strongest signal: {item['label']} at {item['score']}%."
|
| 143 |
+
for item in reversed(strongest)
|
| 144 |
+
]
|
| 145 |
+
notes.extend(
|
| 146 |
+
f"Weakness to respect: {item['label']} at {item['score']}%."
|
| 147 |
+
for item in weakest
|
| 148 |
+
)
|
| 149 |
+
return notes
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def split_for_pair(pair_hash: str, train_ratio: float, eval_ratio: float) -> str:
|
| 153 |
+
bucket = int(pair_hash[:8], 16) / 0xFFFFFFFF
|
| 154 |
+
if bucket < train_ratio:
|
| 155 |
+
return "train"
|
| 156 |
+
if bucket < train_ratio + eval_ratio:
|
| 157 |
+
return "eval"
|
| 158 |
+
return "test"
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None:
|
| 162 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 163 |
+
with path.open("w", encoding="utf-8") as handle:
|
| 164 |
+
for row in rows:
|
| 165 |
+
handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True))
|
| 166 |
+
handle.write("\n")
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def main() -> None:
|
| 170 |
+
parser = argparse.ArgumentParser(description="Prepare de-identified loveGPT matchmaker pairwise SFT data.")
|
| 171 |
+
parser.add_argument("--input", required=True, type=Path, help="Encrypted matchmaker JSONL or plain profile JSONL.")
|
| 172 |
+
parser.add_argument("--input-format", choices=["encrypted-matchmaker", "plain-profiles"], default="encrypted-matchmaker")
|
| 173 |
+
parser.add_argument("--out-dir", type=Path, default=Path("training/outputs/datasets/matchmaker"))
|
| 174 |
+
parser.add_argument("--max-pairs", type=int, default=20000)
|
| 175 |
+
parser.add_argument("--seed", type=int, default=17)
|
| 176 |
+
parser.add_argument("--train-ratio", type=float, default=0.82)
|
| 177 |
+
parser.add_argument("--eval-ratio", type=float, default=0.09)
|
| 178 |
+
parser.add_argument("--feedback", type=Path, help="Optional Dataclaw-only JSONL of pair labels that override teacher targets.")
|
| 179 |
+
args = parser.parse_args()
|
| 180 |
+
|
| 181 |
+
require_training_ack()
|
| 182 |
+
profiles = load_plain_profiles(args.input) if args.input_format == "plain-profiles" else load_encrypted_matchmaker_profiles(args.input)
|
| 183 |
+
profiles = [profile for profile in profiles if profile.get("profileComplete") or profile.get("answers")]
|
| 184 |
+
if len(profiles) < 2:
|
| 185 |
+
raise SystemExit("Need at least two profiles to build pairwise examples.")
|
| 186 |
+
|
| 187 |
+
pairs = list(itertools.combinations(profiles, 2))
|
| 188 |
+
random.Random(args.seed).shuffle(pairs)
|
| 189 |
+
pairs = pairs[: args.max_pairs]
|
| 190 |
+
feedback_by_pair = load_feedback(args.feedback, profiles)
|
| 191 |
+
|
| 192 |
+
rows: list[dict[str, Any]] = []
|
| 193 |
+
for profile_a, profile_b in pairs:
|
| 194 |
+
pair_hash = stable_hash("::".join(sorted([anonymized_profile_key(profile_a), anonymized_profile_key(profile_b)])), salt="lovegpt-pair")
|
| 195 |
+
rows.append(build_pair_example(profile_a, profile_b, split_for_pair(pair_hash, args.train_ratio, args.eval_ratio), feedback_by_pair.get(pair_hash)))
|
| 196 |
+
|
| 197 |
+
for split in ("train", "eval", "test"):
|
| 198 |
+
split_rows = [row for row in rows if row["split"] == split]
|
| 199 |
+
write_jsonl(args.out_dir / f"{split}.jsonl", split_rows)
|
| 200 |
+
|
| 201 |
+
manifest = {
|
| 202 |
+
"schema": "lovegpt.matchmaker.dataset_manifest.v1",
|
| 203 |
+
"profileCount": len(profiles),
|
| 204 |
+
"pairCount": len(rows),
|
| 205 |
+
"splits": {split: sum(1 for row in rows if row["split"] == split) for split in ("train", "eval", "test")},
|
| 206 |
+
"feedbackOverrides": sum(1 for row in rows if row.get("targetSource") == "feedback-v1"),
|
| 207 |
+
"inputFormat": args.input_format,
|
| 208 |
+
"privacy": "De-identified profile view: no user ids, usernames, names, exact ages, exact locations, contacts, or URLs.",
|
| 209 |
+
}
|
| 210 |
+
(args.out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8")
|
| 211 |
+
print(json.dumps(manifest, indent=2, sort_keys=True))
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
if __name__ == "__main__":
|
| 215 |
+
main()
|
training/requirements-training.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
accelerate>=0.34
|
| 2 |
+
bitsandbytes>=0.43; platform_system != "Windows"
|
| 3 |
+
cryptography>=42,<47
|
| 4 |
+
datasets>=2.20
|
| 5 |
+
huggingface_hub>=0.33.5,<2
|
| 6 |
+
peft>=0.12
|
| 7 |
+
pyyaml>=6.0.2
|
| 8 |
+
scikit-learn>=1.5
|
| 9 |
+
torch>=2.3
|
| 10 |
+
transformers>=4.45
|
training/src/lovegpt_matchmaker_training/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Training utilities for the loveGPT matchmaker model."""
|
| 2 |
+
|
| 3 |
+
__all__ = ["__version__"]
|
| 4 |
+
|
| 5 |
+
__version__ = "0.1.0"
|
training/src/lovegpt_matchmaker_training/crypto.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import hashlib
|
| 5 |
+
import hmac
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
| 11 |
+
|
| 12 |
+
MATCHMAKER_TABLE_SCHEMA = "opendatebase.matchmaker.table.v1"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def b64url_decode(value: str) -> bytes:
|
| 16 |
+
return base64.urlsafe_b64decode(value + ("=" * (-len(value) % 4)))
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def canonical_json(value: Any) -> bytes:
|
| 20 |
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _dataclaw_profile_key() -> tuple[bytes, str]:
|
| 24 |
+
raw = os.getenv("DATACLAW_PROFILE_KEY", "").strip()
|
| 25 |
+
if not raw:
|
| 26 |
+
raise SystemExit("DATACLAW_PROFILE_KEY or MATCHMAKER_TABLE_KEY is required to decrypt encrypted matchmaker rows.")
|
| 27 |
+
try:
|
| 28 |
+
decoded = b64url_decode(raw)
|
| 29 |
+
except Exception:
|
| 30 |
+
decoded = b""
|
| 31 |
+
if len(decoded) >= 32:
|
| 32 |
+
return hashlib.sha256(decoded).digest(), "dataclaw-secret"
|
| 33 |
+
return hashlib.sha256(raw.encode("utf-8")).digest(), "dataclaw-secret"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def matchmaker_table_key() -> bytes:
|
| 37 |
+
raw = os.getenv("MATCHMAKER_TABLE_KEY", "").strip()
|
| 38 |
+
if raw:
|
| 39 |
+
try:
|
| 40 |
+
decoded = b64url_decode(raw)
|
| 41 |
+
except Exception:
|
| 42 |
+
decoded = b""
|
| 43 |
+
if len(decoded) >= 32:
|
| 44 |
+
return hashlib.sha256(decoded).digest()
|
| 45 |
+
return hashlib.sha256(raw.encode("utf-8")).digest()
|
| 46 |
+
|
| 47 |
+
key, _scope = _dataclaw_profile_key()
|
| 48 |
+
return hashlib.sha256(key + b"opendatebase-matchmaker-table").digest()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def scoped_key_id(key: bytes, scope: bytes) -> str:
|
| 52 |
+
return hmac.new(key, scope, hashlib.sha256).hexdigest()[:16]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def decrypt_matchmaker_row(row: dict[str, Any] | str) -> dict[str, Any]:
|
| 56 |
+
encrypted = json.loads(row) if isinstance(row, str) else row
|
| 57 |
+
if encrypted.get("schema") != MATCHMAKER_TABLE_SCHEMA:
|
| 58 |
+
raise ValueError("Unsupported matchmaker row schema.")
|
| 59 |
+
key = matchmaker_table_key()
|
| 60 |
+
expected_key_id = scoped_key_id(key, b"opendatebase-matchmaker-table")
|
| 61 |
+
if not hmac.compare_digest(str(encrypted.get("keyId", "")), expected_key_id):
|
| 62 |
+
raise ValueError("Matchmaker table key mismatch.")
|
| 63 |
+
plaintext = AESGCM(key).decrypt(
|
| 64 |
+
b64url_decode(str(encrypted["nonce"])),
|
| 65 |
+
b64url_decode(str(encrypted["ciphertext"])),
|
| 66 |
+
canonical_json(encrypted["aad"]),
|
| 67 |
+
)
|
| 68 |
+
return json.loads(plaintext.decode("utf-8"))
|
training/src/lovegpt_matchmaker_training/features.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from .privacy import age_bucket, normalize_text, redact_contacts
|
| 6 |
+
from .questions import load_questions
|
| 7 |
+
from .schema import ProfilePayload, Question
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
SENSITIVE_PROFILE_KEYS = {
|
| 11 |
+
"display_name",
|
| 12 |
+
"name",
|
| 13 |
+
"full_name",
|
| 14 |
+
"location",
|
| 15 |
+
"email",
|
| 16 |
+
"phone",
|
| 17 |
+
"contact",
|
| 18 |
+
"handle",
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _answer_text(answer: Any) -> str:
|
| 23 |
+
if isinstance(answer, list):
|
| 24 |
+
return redact_contacts("; ".join(normalize_text(item) for item in answer))
|
| 25 |
+
return redact_contacts(normalize_text(answer))
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def sanitize_profile(payload: ProfilePayload, questions: list[Question] | None = None) -> dict[str, Any]:
|
| 29 |
+
"""Return the training-safe view of a questionnaire payload.
|
| 30 |
+
|
| 31 |
+
This intentionally excludes user ids, usernames, profile ids, exact location,
|
| 32 |
+
exact age, and display names. It keeps questionnaire content because that is
|
| 33 |
+
the signal the matchmaker must learn from.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
_, all_questions = load_questions()
|
| 37 |
+
questions = questions or all_questions
|
| 38 |
+
answers = payload.get("answers", {}) if isinstance(payload.get("answers"), dict) else {}
|
| 39 |
+
profile = payload.get("profile", {}) if isinstance(payload.get("profile"), dict) else {}
|
| 40 |
+
|
| 41 |
+
sanitized_answers: dict[str, dict[str, Any]] = {}
|
| 42 |
+
for question in questions:
|
| 43 |
+
raw = answers.get(question.id)
|
| 44 |
+
if not isinstance(raw, dict):
|
| 45 |
+
continue
|
| 46 |
+
answer = _answer_text(raw.get("answer"))
|
| 47 |
+
if not answer:
|
| 48 |
+
continue
|
| 49 |
+
sanitized_answers[question.id] = {
|
| 50 |
+
"question": question.prompt,
|
| 51 |
+
"categoryId": question.category_id,
|
| 52 |
+
"tags": list(question.tags),
|
| 53 |
+
"answer": answer,
|
| 54 |
+
"dealbreakerSeverity": raw.get("dealbreakerSeverity"),
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
return {
|
| 58 |
+
"ageBucket": age_bucket(profile.get("age", "")),
|
| 59 |
+
"intent": redact_contacts(normalize_text(profile.get("intent", ""))) or "unknown",
|
| 60 |
+
"profileComplete": bool(payload.get("profileComplete")),
|
| 61 |
+
"answerCount": len(sanitized_answers),
|
| 62 |
+
"answers": sanitized_answers,
|
| 63 |
+
"captivatingTraits": [redact_contacts(normalize_text(item)) for item in payload.get("captivatingTraits", [])][:16],
|
| 64 |
+
"disgustTriggers": [redact_contacts(normalize_text(item)) for item in payload.get("disgustTriggers", [])][:16],
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def compact_profile_for_prompt(profile: dict[str, Any], max_answer_chars: int = 420) -> dict[str, Any]:
|
| 69 |
+
answers = {}
|
| 70 |
+
for question_id, answer in profile.get("answers", {}).items():
|
| 71 |
+
text = normalize_text(answer.get("answer", ""))
|
| 72 |
+
answers[question_id] = {
|
| 73 |
+
"categoryId": answer.get("categoryId"),
|
| 74 |
+
"tags": answer.get("tags", []),
|
| 75 |
+
"answer": text[:max_answer_chars],
|
| 76 |
+
"dealbreakerSeverity": answer.get("dealbreakerSeverity"),
|
| 77 |
+
}
|
| 78 |
+
return {
|
| 79 |
+
"ageBucket": profile.get("ageBucket", "unknown"),
|
| 80 |
+
"intent": profile.get("intent", "unknown"),
|
| 81 |
+
"answerCount": profile.get("answerCount", len(answers)),
|
| 82 |
+
"answers": answers,
|
| 83 |
+
"captivatingTraits": profile.get("captivatingTraits", [])[:8],
|
| 84 |
+
"disgustTriggers": profile.get("disgustTriggers", [])[:8],
|
| 85 |
+
}
|
training/src/lovegpt_matchmaker_training/privacy.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import hmac
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
CONTACT_PATTERNS = [
|
| 10 |
+
re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.IGNORECASE),
|
| 11 |
+
re.compile(r"\b(?:\+?1[\s.-]?)?(?:\(?\d{3}\)?[\s.-]?)\d{3}[\s.-]?\d{4}\b"),
|
| 12 |
+
re.compile(r"\b(?:instagram|insta|ig|snapchat|telegram|discord|whatsapp|signal)\s*[:@]\s*[A-Za-z0-9_.-]+\b", re.IGNORECASE),
|
| 13 |
+
re.compile(r"\bhttps?://\S+\b", re.IGNORECASE),
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def normalize_text(value: Any) -> str:
|
| 18 |
+
return re.sub(r"\s+", " ", str(value or "").strip())
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def redact_contacts(value: str) -> str:
|
| 22 |
+
text = normalize_text(value)
|
| 23 |
+
for pattern in CONTACT_PATTERNS:
|
| 24 |
+
text = pattern.sub("[redacted-contact]", text)
|
| 25 |
+
return text
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def stable_hash(value: str, salt: str = "lovegpt-training") -> str:
|
| 29 |
+
digest = hmac.new(salt.encode("utf-8"), value.encode("utf-8"), hashlib.sha256).hexdigest()
|
| 30 |
+
return digest[:16]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def age_bucket(value: Any) -> str:
|
| 34 |
+
text = normalize_text(value)
|
| 35 |
+
match = re.search(r"\d{1,3}", text)
|
| 36 |
+
if not match:
|
| 37 |
+
return "unknown"
|
| 38 |
+
age = max(18, min(99, int(match.group(0))))
|
| 39 |
+
lower = (age // 5) * 5
|
| 40 |
+
upper = lower + 4
|
| 41 |
+
return f"{lower}-{upper}"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def require_training_ack() -> None:
|
| 45 |
+
if os.getenv("MATCHMAKER_TRAINING_ACK") != "1":
|
| 46 |
+
raise SystemExit(
|
| 47 |
+
"Set MATCHMAKER_TRAINING_ACK=1 before writing model-training data. "
|
| 48 |
+
"This acknowledges that Dataclaw-authorized profile text will be de-identified and used for training."
|
| 49 |
+
)
|
training/src/lovegpt_matchmaker_training/questions.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from .schema import Question
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def project_root() -> Path:
|
| 10 |
+
return Path(__file__).resolve().parents[3]
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def load_questions(path: Path | None = None) -> tuple[list[dict], list[Question]]:
|
| 14 |
+
questions_path = path or project_root() / "shared" / "questions.json"
|
| 15 |
+
document = json.loads(questions_path.read_text(encoding="utf-8"))
|
| 16 |
+
questions = [
|
| 17 |
+
Question(
|
| 18 |
+
id=item["id"],
|
| 19 |
+
number=int(item["number"]),
|
| 20 |
+
category_id=item["categoryId"],
|
| 21 |
+
prompt=item["prompt"],
|
| 22 |
+
weight=float(item["weight"]),
|
| 23 |
+
tags=tuple(item.get("tags", [])),
|
| 24 |
+
)
|
| 25 |
+
for item in document["questions"]
|
| 26 |
+
]
|
| 27 |
+
return list(document["categories"]), questions
|
training/src/lovegpt_matchmaker_training/schema.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass(frozen=True)
|
| 8 |
+
class Question:
|
| 9 |
+
id: str
|
| 10 |
+
number: int
|
| 11 |
+
category_id: str
|
| 12 |
+
prompt: str
|
| 13 |
+
weight: float
|
| 14 |
+
tags: tuple[str, ...]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass(frozen=True)
|
| 18 |
+
class HarnessDimension:
|
| 19 |
+
id: str
|
| 20 |
+
label: str
|
| 21 |
+
weight: float
|
| 22 |
+
tags: frozenset[str]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
ProfilePayload = dict[str, Any]
|
| 26 |
+
PairExample = dict[str, Any]
|
training/src/lovegpt_matchmaker_training/teacher.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
import re
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from .questions import load_questions
|
| 8 |
+
from .schema import HarnessDimension, ProfilePayload
|
| 9 |
+
|
| 10 |
+
MATCHMAKER_HARNESS = [
|
| 11 |
+
HarnessDimension("values_future", "Values and future direction", 0.18, frozenset({"values", "future", "long_term_fit", "life_design"})),
|
| 12 |
+
HarnessDimension("emotional_safety", "Emotional safety and attachment", 0.16, frozenset({"emotional_safety", "attachment", "trust", "care", "support"})),
|
| 13 |
+
HarnessDimension("captivation", "Captivating traits and admiration", 0.14, frozenset({"captivating_traits", "attraction", "desire", "chemistry"})),
|
| 14 |
+
HarnessDimension("disgust_filters", "Disgust filters and aversions", 0.17, frozenset({"disgust", "hygiene", "hard_filter", "lifestyle_filters"})),
|
| 15 |
+
HarnessDimension("conflict_repair", "Conflict and repair", 0.13, frozenset({"conflict", "repair", "apology", "communication"})),
|
| 16 |
+
HarnessDimension("lifestyle", "Lifestyle and practical future", 0.10, frozenset({"lifestyle", "money", "family", "home", "health", "growth"})),
|
| 17 |
+
HarnessDimension("body_attraction", "Body-type attraction alignment", 0.12, frozenset({"body_type", "desired_body_type", "physical_attraction", "body_preference"})),
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def lexical_score(a: str, b: str) -> float:
|
| 22 |
+
a_terms = {term for term in re.findall(r"[a-z0-9]{3,}", a.lower())}
|
| 23 |
+
b_terms = {term for term in re.findall(r"[a-z0-9]{3,}", b.lower())}
|
| 24 |
+
if not a_terms or not b_terms:
|
| 25 |
+
return 0.0
|
| 26 |
+
return len(a_terms & b_terms) / math.sqrt(len(a_terms) * len(b_terms))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def answers_for_tags(payload: ProfilePayload, tags: frozenset[str]) -> str:
|
| 30 |
+
_, questions = load_questions()
|
| 31 |
+
answers = payload.get("answers", {})
|
| 32 |
+
chunks: list[str] = []
|
| 33 |
+
for question in questions:
|
| 34 |
+
if question.id not in answers or not (set(question.tags) & tags):
|
| 35 |
+
continue
|
| 36 |
+
answer = answers[question.id].get("answer", "")
|
| 37 |
+
if isinstance(answer, list):
|
| 38 |
+
chunks.extend(str(item) for item in answer)
|
| 39 |
+
else:
|
| 40 |
+
chunks.append(str(answer))
|
| 41 |
+
return " ".join(chunks)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def question_answer(payload: ProfilePayload, question_id: str) -> str:
|
| 45 |
+
answer = payload.get("answers", {}).get(question_id, {}).get("answer", "")
|
| 46 |
+
if isinstance(answer, list):
|
| 47 |
+
return " ".join(str(item) for item in answer)
|
| 48 |
+
return str(answer)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def open_preference_text(value: str) -> bool:
|
| 52 |
+
lowered = value.lower()
|
| 53 |
+
return any(term in lowered for term in ("open", "flexible", "range", "many", "varied", "not picky", "any", "all body"))
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def body_alignment_score(a: ProfilePayload, b: ProfilePayload) -> float:
|
| 57 |
+
a_own = " ".join([question_answer(a, "q37"), question_answer(a, "q40")])
|
| 58 |
+
a_wants = " ".join([question_answer(a, "q38"), question_answer(a, "q39")])
|
| 59 |
+
b_own = " ".join([question_answer(b, "q37"), question_answer(b, "q40")])
|
| 60 |
+
b_wants = " ".join([question_answer(b, "q38"), question_answer(b, "q39")])
|
| 61 |
+
if not a_wants or not b_wants:
|
| 62 |
+
return 0.0
|
| 63 |
+
a_to_b = 0.75 if open_preference_text(a_wants) else lexical_score(a_wants, b_own)
|
| 64 |
+
b_to_a = 0.75 if open_preference_text(b_wants) else lexical_score(b_wants, a_own)
|
| 65 |
+
return max(0.0, min(1.0, (a_to_b + b_to_a) / 2))
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def strict_matchmaker_judgment(a: ProfilePayload, b: ProfilePayload) -> dict[str, Any]:
|
| 69 |
+
dimensions: list[dict[str, Any]] = []
|
| 70 |
+
weighted = 0.0
|
| 71 |
+
for item in MATCHMAKER_HARNESS:
|
| 72 |
+
score = body_alignment_score(a, b) if item.id == "body_attraction" else lexical_score(answers_for_tags(a, item.tags), answers_for_tags(b, item.tags))
|
| 73 |
+
weighted += score * item.weight
|
| 74 |
+
dimensions.append(
|
| 75 |
+
{
|
| 76 |
+
"id": item.id,
|
| 77 |
+
"label": item.label,
|
| 78 |
+
"score": round(score * 100, 1),
|
| 79 |
+
"weight": item.weight,
|
| 80 |
+
}
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
body_score = next(row["score"] for row in dimensions if row["id"] == "body_attraction")
|
| 84 |
+
disgust_score = next(row["score"] for row in dimensions if row["id"] == "disgust_filters")
|
| 85 |
+
total = round(weighted * 100, 1)
|
| 86 |
+
passed = total >= 62 and body_score >= 20 and disgust_score >= 12
|
| 87 |
+
return {
|
| 88 |
+
"harness": "strict-v1-teacher",
|
| 89 |
+
"compatibility": total,
|
| 90 |
+
"passed": passed,
|
| 91 |
+
"dimensions": dimensions,
|
| 92 |
+
"requirements": {
|
| 93 |
+
"minimumCompatibility": 62,
|
| 94 |
+
"minimumBodyAlignment": 20,
|
| 95 |
+
"minimumDisgustAlignment": 12,
|
| 96 |
+
},
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def label_from_score(score: float) -> str:
|
| 101 |
+
if score >= 78:
|
| 102 |
+
return "high_match"
|
| 103 |
+
if score >= 62:
|
| 104 |
+
return "candidate"
|
| 105 |
+
if score >= 42:
|
| 106 |
+
return "weak_match"
|
| 107 |
+
return "do_not_match"
|
training/train_lora.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 10 |
+
sys.path.insert(0, str(ROOT / "training" / "src"))
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def load_config(path: Path) -> dict[str, Any]:
|
| 14 |
+
import yaml
|
| 15 |
+
|
| 16 |
+
return yaml.safe_load(path.read_text(encoding="utf-8"))
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def load_jsonl(path: Path) -> list[dict[str, Any]]:
|
| 20 |
+
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def format_messages(tokenizer: Any, row: dict[str, Any]) -> str:
|
| 24 |
+
messages = row["messages"]
|
| 25 |
+
if getattr(tokenizer, "chat_template", None):
|
| 26 |
+
return tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
|
| 27 |
+
return "\n\n".join(f"{msg['role'].upper()}: {msg['content']}" for msg in messages)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def main() -> None:
|
| 31 |
+
parser = argparse.ArgumentParser(description="Train a small loveGPT matchmaker LoRA adapter.")
|
| 32 |
+
parser.add_argument("--config", type=Path, default=Path("training/config/matchmaker_1b_lora.yaml"))
|
| 33 |
+
args = parser.parse_args()
|
| 34 |
+
|
| 35 |
+
import torch
|
| 36 |
+
from datasets import Dataset
|
| 37 |
+
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
|
| 38 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig, DataCollatorForLanguageModeling, Trainer, TrainingArguments
|
| 39 |
+
|
| 40 |
+
cfg = load_config(args.config)
|
| 41 |
+
model_name = cfg["model"]["base_model"]
|
| 42 |
+
train_rows = load_jsonl(Path(cfg["data"]["train_file"]))
|
| 43 |
+
eval_rows = load_jsonl(Path(cfg["data"]["eval_file"]))
|
| 44 |
+
|
| 45 |
+
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=cfg["model"].get("trust_remote_code", False))
|
| 46 |
+
if tokenizer.pad_token is None:
|
| 47 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 48 |
+
|
| 49 |
+
train_dataset = Dataset.from_list([{"text": format_messages(tokenizer, row)} for row in train_rows])
|
| 50 |
+
eval_dataset = Dataset.from_list([{"text": format_messages(tokenizer, row)} for row in eval_rows])
|
| 51 |
+
|
| 52 |
+
quant_cfg = None
|
| 53 |
+
if cfg["model"].get("load_in_4bit", True):
|
| 54 |
+
quant_cfg = BitsAndBytesConfig(
|
| 55 |
+
load_in_4bit=True,
|
| 56 |
+
bnb_4bit_quant_type="nf4",
|
| 57 |
+
bnb_4bit_use_double_quant=True,
|
| 58 |
+
bnb_4bit_compute_dtype=torch.bfloat16 if torch.cuda.is_available() else torch.float32,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 62 |
+
model_name,
|
| 63 |
+
quantization_config=quant_cfg,
|
| 64 |
+
device_map=cfg["model"].get("device_map", "auto"),
|
| 65 |
+
trust_remote_code=cfg["model"].get("trust_remote_code", False),
|
| 66 |
+
)
|
| 67 |
+
if quant_cfg is not None:
|
| 68 |
+
model = prepare_model_for_kbit_training(model)
|
| 69 |
+
|
| 70 |
+
lora = LoraConfig(
|
| 71 |
+
r=int(cfg["lora"]["r"]),
|
| 72 |
+
lora_alpha=int(cfg["lora"]["alpha"]),
|
| 73 |
+
lora_dropout=float(cfg["lora"]["dropout"]),
|
| 74 |
+
bias="none",
|
| 75 |
+
task_type="CAUSAL_LM",
|
| 76 |
+
target_modules=list(cfg["lora"]["target_modules"]),
|
| 77 |
+
)
|
| 78 |
+
model = get_peft_model(model, lora)
|
| 79 |
+
|
| 80 |
+
training_args = TrainingArguments(
|
| 81 |
+
output_dir=cfg["training"]["output_dir"],
|
| 82 |
+
per_device_train_batch_size=int(cfg["training"]["per_device_train_batch_size"]),
|
| 83 |
+
per_device_eval_batch_size=int(cfg["training"]["per_device_eval_batch_size"]),
|
| 84 |
+
gradient_accumulation_steps=int(cfg["training"]["gradient_accumulation_steps"]),
|
| 85 |
+
learning_rate=float(cfg["training"]["learning_rate"]),
|
| 86 |
+
num_train_epochs=float(cfg["training"]["num_train_epochs"]),
|
| 87 |
+
warmup_ratio=float(cfg["training"]["warmup_ratio"]),
|
| 88 |
+
logging_steps=int(cfg["training"]["logging_steps"]),
|
| 89 |
+
evaluation_strategy="steps",
|
| 90 |
+
eval_steps=int(cfg["training"]["eval_steps"]),
|
| 91 |
+
save_steps=int(cfg["training"]["save_steps"]),
|
| 92 |
+
save_total_limit=int(cfg["training"]["save_total_limit"]),
|
| 93 |
+
bf16=bool(cfg["training"].get("bf16", True)) and torch.cuda.is_available(),
|
| 94 |
+
fp16=bool(cfg["training"].get("fp16", False)) and torch.cuda.is_available(),
|
| 95 |
+
report_to=list(cfg["training"].get("report_to", [])),
|
| 96 |
+
remove_unused_columns=False,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
max_seq_length = int(cfg["training"]["max_seq_length"])
|
| 100 |
+
|
| 101 |
+
def tokenize(batch: dict[str, list[str]]) -> dict[str, Any]:
|
| 102 |
+
return tokenizer(
|
| 103 |
+
batch["text"],
|
| 104 |
+
truncation=True,
|
| 105 |
+
max_length=max_seq_length,
|
| 106 |
+
padding=False,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
tokenized_train = train_dataset.map(tokenize, batched=True, remove_columns=["text"])
|
| 110 |
+
tokenized_eval = eval_dataset.map(tokenize, batched=True, remove_columns=["text"])
|
| 111 |
+
collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False)
|
| 112 |
+
|
| 113 |
+
trainer = Trainer(
|
| 114 |
+
model=model,
|
| 115 |
+
args=training_args,
|
| 116 |
+
train_dataset=tokenized_train,
|
| 117 |
+
eval_dataset=tokenized_eval,
|
| 118 |
+
data_collator=collator,
|
| 119 |
+
)
|
| 120 |
+
trainer.train()
|
| 121 |
+
trainer.save_model(cfg["training"]["adapter_output_dir"])
|
| 122 |
+
tokenizer.save_pretrained(cfg["training"]["adapter_output_dir"])
|
| 123 |
+
print(f"Saved adapter to {cfg['training']['adapter_output_dir']}")
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
if __name__ == "__main__":
|
| 127 |
+
main()
|