Eric Xu commited on
Commit
a4bb654
·
1 Parent(s): 74851ea

Replace hardcoded keyword filters with LLM-based target-aware category mapping

Browse files

The stratified sampler used hardcoded substring matching to bucket 567
occupation values into 7 categories — misclassifying many (e.g. "sales
engineer" → tech) and dumping ~200 categories into "other". Since this is
fundamentally a retrieval problem, the LLM needs to see all categories
relative to the evaluation target.

- Add build_category_map.py: sends unique category values + entity
description to LLM, which returns 6-10 groups based on how those
categories would differ in evaluating the specific entity. Cached per
entity so the LLM call happens once.
- Remove occupation_bucket() and education_tier() from stratified_sampler.py;
education uses raw 7 values directly, occupation uses LLM mapping when
--entity is provided or raw values otherwise.
- Fix generate_cohort.py: add missing fields (sex, country, marital_status,
interests) so generated cohorts are compatible with the rest of the pipeline.
- Remove interests truncation (hobbies[:5] + skills[:3]) in persona_loader.py;
pass through full lists from the dataset.
- Remove unused MBTI_TYPES constant.
- Fix build_category_map.py data path to use project's data/nemotron/
(consistent with setup_data.py) instead of hardcoded ~/data/nvidia/ path.

AGENT.md CHANGED
@@ -89,9 +89,8 @@ Run the stratified sampler with the user's parameters:
89
 
90
  ```bash
91
  uv run python scripts/stratified_sampler.py \
92
- --population <dataset_or_generated> \
93
- --filters '{"sex": "Female", "state": "IL", "age_min": 25, "age_max": 50}' \
94
- --dimensions '["age_bracket", "marital_status", "education_tier"]' \
95
  --total 50 \
96
  --output data/cohort.json
97
  ```
 
89
 
90
  ```bash
91
  uv run python scripts/stratified_sampler.py \
92
+ --input data/filtered.json \
93
+ --entity entities/<entity>.md \
 
94
  --total 50 \
95
  --output data/cohort.json
96
  ```
SKILL.md CHANGED
@@ -71,9 +71,10 @@ uv run python scripts/persona_loader.py \
71
  --filters '{"sex": "...", "state": "...", "age_min": N, "age_max": N}' \
72
  --output data/filtered.json
73
 
74
- # Stratified sample
75
  uv run python scripts/stratified_sampler.py \
76
  --input data/filtered.json \
 
77
  --total 50 \
78
  --output data/cohort.json
79
  ```
 
71
  --filters '{"sex": "...", "state": "...", "age_min": N, "age_max": N}' \
72
  --output data/filtered.json
73
 
74
+ # Stratified sample (--entity enables LLM-based occupation bucketing)
75
  uv run python scripts/stratified_sampler.py \
76
  --input data/filtered.json \
77
+ --entity entities/<entity>.md \
78
  --total 50 \
79
  --output data/cohort.json
80
  ```
scripts/build_category_map.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Build a target-aware category mapping for stratified sampling.
3
+
4
+ Instead of hardcoded keyword buckets, this sends all unique category values
5
+ from the dataset to an LLM along with the entity description. The LLM returns
6
+ a grouping that's meaningful for the specific evaluation target.
7
+
8
+ The mapping is cached so it's only generated once per entity + field combination.
9
+
10
+ Usage:
11
+ # Build occupation mapping for a specific entity
12
+ uv run python scripts/build_category_map.py \
13
+ --entity entities/my_product.md \
14
+ --field occupation \
15
+ --output data/occupation_map.json
16
+
17
+ # As a library
18
+ from build_category_map import load_or_build_map
19
+ occ_map = load_or_build_map("occupation", entity_text, unique_values)
20
+ """
21
+
22
+ import json
23
+ import hashlib
24
+ import os
25
+ import re
26
+ import argparse
27
+ from pathlib import Path
28
+
29
+ from dotenv import load_dotenv
30
+
31
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
32
+ load_dotenv(PROJECT_ROOT / ".env")
33
+
34
+ from openai import OpenAI
35
+
36
+ CACHE_DIR = PROJECT_ROOT / "data" / "category_maps"
37
+
38
+ SYSTEM_PROMPT = """You are a research methodology assistant. Your job is to create
39
+ meaningful groupings of categorical values for stratified sampling.
40
+
41
+ You will receive:
42
+ 1. A list of unique category values from a dataset
43
+ 2. A description of the entity being evaluated
44
+
45
+ Create 6-10 groups that ensure the evaluation cohort captures meaningfully
46
+ different perspectives on the entity. Groups should reflect how people in these
47
+ categories would DIFFER in their evaluation of the entity — not just demographic
48
+ similarity.
49
+
50
+ You MUST respond with valid JSON only."""
51
+
52
+ MAP_PROMPT = """## Entity Being Evaluated
53
+
54
+ {entity}
55
+
56
+ ---
57
+
58
+ ## Unique Values to Group
59
+
60
+ Field: {field}
61
+ Values ({count} unique):
62
+
63
+ {values}
64
+
65
+ ---
66
+
67
+ ## Task
68
+
69
+ Group these {count} values into 6-10 buckets that capture meaningfully different
70
+ perspectives on the entity above. Every value must appear in exactly one bucket.
71
+
72
+ Think about: Who would evaluate this entity differently? What professional/life
73
+ context changes how someone perceives this?
74
+
75
+ Return JSON:
76
+ {{
77
+ "buckets": [
78
+ {{
79
+ "name": "<short bucket label>",
80
+ "rationale": "<why this group evaluates the entity differently>",
81
+ "values": ["<value1>", "<value2>", ...]
82
+ }}
83
+ ]
84
+ }}"""
85
+
86
+
87
+ def extract_unique_values(field, data_dir=None):
88
+ """Extract unique values for a field from the Nemotron dataset.
89
+
90
+ Uses HuggingFace datasets (load_from_disk), consistent with setup_data.py
91
+ and persona_loader.py.
92
+ """
93
+ from collections import Counter
94
+ from datasets import load_from_disk
95
+
96
+ if data_dir is None:
97
+ data_dir = PROJECT_ROOT / "data" / "nemotron"
98
+
99
+ if not (data_dir / "dataset_info.json").exists():
100
+ raise FileNotFoundError(
101
+ f"Dataset not found at {data_dir}. Run: uv run python scripts/setup_data.py"
102
+ )
103
+
104
+ ds = load_from_disk(str(data_dir))
105
+ return Counter(ds[field])
106
+
107
+
108
+ def build_map(field, entity_text, unique_values, client=None, model=None):
109
+ """Call LLM to build a target-aware category mapping."""
110
+ if client is None:
111
+ client = OpenAI(
112
+ api_key=os.getenv("LLM_API_KEY"),
113
+ base_url=os.getenv("LLM_BASE_URL"),
114
+ )
115
+ if model is None:
116
+ model = os.getenv("LLM_MODEL_NAME")
117
+
118
+ values_text = "\n".join(f" - {v}" for v in sorted(unique_values))
119
+
120
+ prompt = MAP_PROMPT.format(
121
+ entity=entity_text,
122
+ field=field,
123
+ count=len(unique_values),
124
+ values=values_text,
125
+ )
126
+
127
+ resp = client.chat.completions.create(
128
+ model=model,
129
+ messages=[
130
+ {"role": "system", "content": SYSTEM_PROMPT},
131
+ {"role": "user", "content": prompt},
132
+ ],
133
+ response_format={"type": "json_object"},
134
+ max_tokens=16384,
135
+ temperature=0.3,
136
+ )
137
+
138
+ content = resp.choices[0].message.content
139
+ if not content:
140
+ raise RuntimeError("Empty response from LLM")
141
+ content = re.sub(r'<think>[\s\S]*?</think>', '', content).strip()
142
+ data = json.loads(content)
143
+
144
+ # Flatten to value -> bucket_name mapping
145
+ mapping = {}
146
+ for bucket in data["buckets"]:
147
+ for val in bucket["values"]:
148
+ mapping[val] = bucket["name"]
149
+
150
+ # Check coverage
151
+ mapped = set(mapping.keys())
152
+ expected = set(unique_values)
153
+ missing = expected - mapped
154
+ if missing:
155
+ print(f" Warning: {len(missing)} values not mapped by LLM, assigning to 'other':")
156
+ for v in sorted(missing)[:10]:
157
+ print(f" - {v}")
158
+ if len(missing) > 10:
159
+ print(f" ... and {len(missing) - 10} more")
160
+ for v in missing:
161
+ mapping[v] = "other"
162
+
163
+ extra = mapped - expected
164
+ if extra:
165
+ print(f" Note: LLM included {len(extra)} values not in dataset (ignored)")
166
+
167
+ return {
168
+ "field": field,
169
+ "buckets": data["buckets"],
170
+ "mapping": mapping,
171
+ }
172
+
173
+
174
+ def cache_key(field, entity_text):
175
+ """Generate a stable cache key from field + entity content."""
176
+ h = hashlib.sha256(entity_text.encode()).hexdigest()[:12]
177
+ return f"{field}_{h}"
178
+
179
+
180
+ def load_or_build_map(field, entity_text, unique_values,
181
+ client=None, model=None, cache_dir=None):
182
+ """Load cached mapping or build a new one."""
183
+ cache_dir = Path(cache_dir or CACHE_DIR)
184
+ cache_dir.mkdir(parents=True, exist_ok=True)
185
+
186
+ key = cache_key(field, entity_text)
187
+ cache_path = cache_dir / f"{key}.json"
188
+
189
+ if cache_path.exists():
190
+ print(f" Loading cached {field} mapping: {cache_path.name}")
191
+ with open(cache_path) as f:
192
+ data = json.load(f)
193
+ return data["mapping"]
194
+
195
+ print(f" Building {field} mapping ({len(unique_values)} unique values)...")
196
+ data = build_map(field, entity_text, unique_values, client, model)
197
+
198
+ with open(cache_path, "w") as f:
199
+ json.dump(data, f, ensure_ascii=False, indent=2)
200
+ print(f" Cached to {cache_path.name}")
201
+
202
+ # Print bucket summary
203
+ for b in data["buckets"]:
204
+ print(f" {b['name']}: {len(b['values'])} values — {b['rationale']}")
205
+
206
+ return data["mapping"]
207
+
208
+
209
+ def main():
210
+ parser = argparse.ArgumentParser()
211
+ parser.add_argument("--entity", required=True, help="Path to entity document")
212
+ parser.add_argument("--field", default="occupation",
213
+ help="Dataset field to map (default: occupation)")
214
+ parser.add_argument("--data-dir", default=None,
215
+ help="Path to Nemotron arrow shards")
216
+ parser.add_argument("--output", default=None,
217
+ help="Output path (default: data/category_maps/<key>.json)")
218
+ parser.add_argument("--force", action="store_true",
219
+ help="Rebuild even if cached")
220
+ args = parser.parse_args()
221
+
222
+ entity_text = Path(args.entity).read_text()
223
+ counts = extract_unique_values(args.field, args.data_dir and Path(args.data_dir))
224
+ unique_values = list(counts.keys())
225
+
226
+ print(f"Field: {args.field} | {len(unique_values)} unique values")
227
+ print(f"Entity: {args.entity}")
228
+
229
+ if args.force:
230
+ key = cache_key(args.field, entity_text)
231
+ cache_path = CACHE_DIR / f"{key}.json"
232
+ if cache_path.exists():
233
+ cache_path.unlink()
234
+ print(" Cleared cache")
235
+
236
+ mapping = load_or_build_map(args.field, entity_text, unique_values)
237
+
238
+ if args.output:
239
+ out = Path(args.output)
240
+ out.parent.mkdir(parents=True, exist_ok=True)
241
+ with open(out, "w") as f:
242
+ json.dump(mapping, f, ensure_ascii=False, indent=2)
243
+ print(f"\nSaved mapping to {args.output}")
244
+
245
+ # Summary
246
+ from collections import Counter
247
+ bucket_counts = Counter(mapping.values())
248
+ print(f"\nBucket distribution:")
249
+ for bucket, cnt in bucket_counts.most_common():
250
+ print(f" {bucket}: {cnt} categories")
251
+
252
+
253
+ if __name__ == "__main__":
254
+ main()
scripts/generate_cohort.py CHANGED
@@ -57,10 +57,14 @@ Return JSON:
57
  {{
58
  "name": "<realistic full name>",
59
  "age": <integer>,
 
60
  "city": "<city>",
61
  "state": "<state abbreviation>",
 
62
  "education_level": "<high_school | bachelors | graduate | etc>",
63
  "occupation": "<specific job title>",
 
 
64
  "persona": "<200-400 word detailed persona narrative>",
65
  "segment": "{segment_label}"
66
  }}
 
57
  {{
58
  "name": "<realistic full name>",
59
  "age": <integer>,
60
+ "sex": "<Male | Female>",
61
  "city": "<city>",
62
  "state": "<state abbreviation>",
63
+ "country": "USA",
64
  "education_level": "<high_school | bachelors | graduate | etc>",
65
  "occupation": "<specific job title>",
66
+ "marital_status": "<never_married | married | divorced | widowed | separated>",
67
+ "interests": ["<hobby or skill, 3-5 items>"],
68
  "persona": "<200-400 word detailed persona narrative>",
69
  "segment": "{segment_label}"
70
  }}
scripts/persona_loader.py CHANGED
@@ -24,11 +24,6 @@ from datasets import load_from_disk
24
  PROJECT_ROOT = Path(__file__).resolve().parent.parent
25
  DEFAULT_DATA_DIR = PROJECT_ROOT / "data" / "nemotron"
26
 
27
- MBTI_TYPES = [
28
- "INTJ", "INTP", "ENTJ", "ENTP", "INFJ", "INFP", "ENFJ", "ENFP",
29
- "ISTJ", "ISFJ", "ESTJ", "ESFJ", "ISTP", "ISFP", "ESTP", "ESFP",
30
- ]
31
-
32
  # All narrative fields in the dataset, in order of richness
33
  NARRATIVE_FIELDS = [
34
  "persona", "cultural_background", "professional_persona",
@@ -150,7 +145,7 @@ def to_profile(row: dict, user_id: int) -> dict:
150
  "education_level": row.get("education_level", ""),
151
  "marital_status": row.get("marital_status", ""),
152
  "occupation": (row.get("occupation") or "").replace("_", " ").title(),
153
- "interests": hobbies[:5] + skills[:3],
154
  "source_uuid": row.get("uuid", ""),
155
  }
156
 
 
24
  PROJECT_ROOT = Path(__file__).resolve().parent.parent
25
  DEFAULT_DATA_DIR = PROJECT_ROOT / "data" / "nemotron"
26
 
 
 
 
 
 
27
  # All narrative fields in the dataset, in order of richness
28
  NARRATIVE_FIELDS = [
29
  "persona", "cultural_background", "professional_persona",
 
145
  "education_level": row.get("education_level", ""),
146
  "marital_status": row.get("marital_status", ""),
147
  "occupation": (row.get("occupation") or "").replace("_", " ").title(),
148
+ "interests": hobbies + skills,
149
  "source_uuid": row.get("uuid", ""),
150
  }
151
 
scripts/stratified_sampler.py CHANGED
@@ -5,17 +5,23 @@ Stratification is configurable: pass dimension functions that map a row to a
5
  bucket label. The sampler ensures minimum 1 per non-empty stratum, then fills
6
  proportionally with within-stratum diversity on a secondary dimension.
7
 
 
 
 
 
 
8
  Usage:
9
  uv run python scripts/stratified_sampler.py \
10
  --input data/filtered.json \
 
11
  --total 50 \
12
  --output data/cohort.json
13
 
14
- # Or with custom dimensions (as Python expressions)
15
  uv run python scripts/stratified_sampler.py \
16
  --input data/filtered.json \
17
  --total 50 \
18
- --dim-exprs '["age_bracket(r[\"age\"])", "r[\"marital_status\"]", "education_tier(r[\"education_level\"])"]'
19
  """
20
 
21
  import json
@@ -37,30 +43,40 @@ def age_bracket(age: int) -> str:
37
  return "50+"
38
 
39
 
40
- def education_tier(edu: str) -> str:
41
- if edu in ("graduate",): return "graduate"
42
- if edu in ("bachelors",): return "bachelors"
43
- if edu in ("associates", "some_college"): return "some_college"
44
- return "no_degree"
45
-
46
-
47
- def occupation_bucket(occ: str) -> str:
48
- occ = occ.lower()
49
- for kw in ("software", "computer", "data", "web", "engineer", "developer"):
50
- if kw in occ: return "tech"
51
- for kw in ("nurse", "doctor", "physician", "therapist", "health", "medical"):
52
- if kw in occ: return "healthcare"
53
- for kw in ("teacher", "professor", "instructor", "education"):
54
- if kw in occ: return "education"
55
- for kw in ("manager", "accountant", "financial", "analyst", "marketing", "sales"):
56
- if kw in occ: return "business"
57
- for kw in ("artist", "designer", "writer", "musician", "photographer"):
58
- if kw in occ: return "creative"
59
- for kw in ("cashier", "retail", "food", "customer", "secretary", "laborer"):
60
- if kw in occ: return "service"
61
- if occ in ("not in workforce", "no occupation", ""):
62
- return "not_working"
63
- return "other"
 
 
 
 
 
 
 
 
 
 
64
 
65
 
66
  # ── Sampler ───────────────────────────────────────────────────────────────
@@ -141,6 +157,8 @@ def stratified_sample(profiles, dim_fns, total=50, diversity_fn=None, seed=42):
141
  def main():
142
  parser = argparse.ArgumentParser()
143
  parser.add_argument("--input", default="data/filtered.json")
 
 
144
  parser.add_argument("--total", type=int, default=50)
145
  parser.add_argument("--seed", type=int, default=42)
146
  parser.add_argument("--output", default="data/cohort.json")
@@ -150,13 +168,16 @@ def main():
150
  profiles = json.load(f)
151
  print(f"Loaded {len(profiles)} profiles from {args.input}")
152
 
153
- # Default dimensions: age, marital status, education
 
 
 
154
  dim_fns = [
155
  lambda p: age_bracket(p.get("age", 30)),
156
  lambda p: p.get("marital_status", "unknown"),
157
- lambda p: education_tier(p.get("education_level", "")),
158
  ]
159
- diversity_fn = lambda p: occupation_bucket(p.get("occupation", ""))
160
 
161
  selected = stratified_sample(profiles, dim_fns, total=args.total,
162
  diversity_fn=diversity_fn, seed=args.seed)
@@ -173,8 +194,8 @@ def main():
173
  print(f"\nSaved {len(selected)} to {args.output}")
174
  for dim_name, fn in [("Age", lambda p: age_bracket(p.get("age", 30))),
175
  ("Marital", lambda p: p.get("marital_status", "?")),
176
- ("Education", lambda p: education_tier(p.get("education_level", ""))),
177
- ("Occupation", lambda p: occupation_bucket(p.get("occupation", "")))]:
178
  dist = Counter(fn(p) for p in selected)
179
  print(f" {dim_name}: {dict(sorted(dist.items()))}")
180
  print(f" Cities: {len(set(p.get('city','') for p in selected))} unique")
 
5
  bucket label. The sampler ensures minimum 1 per non-empty stratum, then fills
6
  proportionally with within-stratum diversity on a secondary dimension.
7
 
8
+ When --entity is provided, occupation bucketing is done via LLM: the full set
9
+ of unique occupation values is sent to the model along with the entity
10
+ description, so the grouping reflects how different professions would evaluate
11
+ that specific entity. The mapping is cached per entity.
12
+
13
  Usage:
14
  uv run python scripts/stratified_sampler.py \
15
  --input data/filtered.json \
16
+ --entity entities/my_product.md \
17
  --total 50 \
18
  --output data/cohort.json
19
 
20
+ # Without entity (uses raw occupation values, no bucketing)
21
  uv run python scripts/stratified_sampler.py \
22
  --input data/filtered.json \
23
  --total 50 \
24
+ --output data/cohort.json
25
  """
26
 
27
  import json
 
43
  return "50+"
44
 
45
 
46
+ def make_occupation_fn(entity_path=None, profiles=None):
47
+ """
48
+ Build an occupation bucketing function.
49
+
50
+ With --entity: uses LLM to create a target-aware mapping from the full
51
+ set of unique occupation values. Cached per entity content.
52
+
53
+ Without --entity: passes through the raw occupation value.
54
+ """
55
+ if entity_path is None:
56
+ return lambda p: p.get("occupation", "unknown") or "unknown"
57
+
58
+ entity_text = Path(entity_path).read_text()
59
+
60
+ # Collect unique occupation values from the profiles being sampled
61
+ unique_occs = set()
62
+ for p in (profiles or []):
63
+ occ = p.get("occupation", "")
64
+ if occ:
65
+ unique_occs.add(occ)
66
+
67
+ if not unique_occs:
68
+ return lambda p: p.get("occupation", "unknown") or "unknown"
69
+
70
+ import sys
71
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
72
+ from build_category_map import load_or_build_map
73
+ mapping = load_or_build_map("occupation", entity_text, list(unique_occs))
74
+
75
+ def lookup(p):
76
+ occ = p.get("occupation", "")
77
+ return mapping.get(occ, mapping.get(occ.lower(), "other"))
78
+
79
+ return lookup
80
 
81
 
82
  # ── Sampler ───────────────────────────────────────────────────────────────
 
157
  def main():
158
  parser = argparse.ArgumentParser()
159
  parser.add_argument("--input", default="data/filtered.json")
160
+ parser.add_argument("--entity", default=None,
161
+ help="Path to entity document (enables LLM-based occupation bucketing)")
162
  parser.add_argument("--total", type=int, default=50)
163
  parser.add_argument("--seed", type=int, default=42)
164
  parser.add_argument("--output", default="data/cohort.json")
 
168
  profiles = json.load(f)
169
  print(f"Loaded {len(profiles)} profiles from {args.input}")
170
 
171
+ # Build occupation function LLM-based if entity provided, raw passthrough otherwise
172
+ occupation_fn = make_occupation_fn(args.entity, profiles)
173
+
174
+ # Default dimensions: age, marital status, education (raw values)
175
  dim_fns = [
176
  lambda p: age_bracket(p.get("age", 30)),
177
  lambda p: p.get("marital_status", "unknown"),
178
+ lambda p: p.get("education_level", "") or "unknown",
179
  ]
180
+ diversity_fn = occupation_fn
181
 
182
  selected = stratified_sample(profiles, dim_fns, total=args.total,
183
  diversity_fn=diversity_fn, seed=args.seed)
 
194
  print(f"\nSaved {len(selected)} to {args.output}")
195
  for dim_name, fn in [("Age", lambda p: age_bracket(p.get("age", 30))),
196
  ("Marital", lambda p: p.get("marital_status", "?")),
197
+ ("Education", lambda p: p.get("education_level", "") or "unknown"),
198
+ ("Occupation", occupation_fn)]:
199
  dist = Counter(fn(p) for p in selected)
200
  print(f" {dim_name}: {dict(sorted(dist.items()))}")
201
  print(f" Cities: {len(set(p.get('city','') for p in selected))} unique")