mochirank / docs /offline-phase.md
DawnOfJustize's picture
Initial HF Spaces deployment (orphan β€” no history)
7a9cfa1
|
Raw
History Blame Contribute Delete
10.4 kB

Offline Phase β€” Step by Step

Everything here runs with internet on, no time limit. Output artifacts feed the 5-minute ranking step. See architecture/spec.md for the big picture.

5.1 Embedding (precompute_embeddings.py)

Model: BAAI/bge-small-en-v1.5 β€” 33M params, 384 dims, ~4x faster than MiniLM-L6 on CPU, MTEB scores competitive. Ships in ~130MB. Alternatively minishlab/potion-base-8M (Model2Vec) for a pure-numpy fallback that embeds 100K in ~30 seconds.

What to embed β€” per-role utterances, NOT whole profiles: Each candidate gets a list of text chunks, not one blob. Research finding from Malt (2026): embedding per-utterance enables late-interaction scoring and per-requirement coverage computation.

def candidate_to_chunks(c: dict) -> list[str]:
    chunks = []
    # Role descriptions β€” most signal-rich field
    for job in c.get("career_history", []):
        if job.get("description"):
            chunks.append(f"{job['title']} at {job['company']}: {job['description']}")
    # Summary if present
    if c["profile"].get("summary"):
        chunks.append(c["profile"]["summary"])
    # Headline
    if c["profile"].get("headline"):
        chunks.append(c["profile"]["headline"])
    return chunks or [c["profile"].get("current_title", "")]

Save:

  • candidate_embeddings.npy β€” shape (N_chunks_total, 384), float16 to halve disk
  • candidate_ids.json β€” list of candidate_ids, one per chunk row (allows grouping back)
  • At load time, group by candidate_id and pool (max or mean) for global candidate vector

Runtime estimate: bge-small + batch_size=512 β†’ ~8–12 min for 100K on 8-core CPU. This is pre-computation; no time limit.

5.2 Hypothetical Resume Generation (generate_hypothetical.py)

Why: ConFit v2 (ACL 2025) showed 17.5% nDCG improvement by embedding a hypothetical ideal resume alongside the JD before scoring. The format asymmetry (discursive JD vs structured resume) kills naive cosine similarity. Generating ideal resumes bridges the gap.

Novel addition: anti-persona resumes. Not in any paper. Generates negative anchors matching exactly the failure modes Redrob planted in the dataset.

Prompt for ideal resumes:

IDEAL_PERSONA_PROMPT = """
You are generating a realistic candidate profile for the following job description.
Create a profile that would be a STRONG FIT β€” a real person with consistent career history,
specific accomplishments, and natural language (no keyword stuffing).

Generate {n} different profiles. Each should be a different archetype:
1. The IR veteran β€” 8yr, built search/ranking at product company pre-LLM era, now adding modern ML
2. The startup ML shipper β€” 6yr, 2-3 startups, shipped RAG/rec-sys to real users, scrappy
3. The platform engineer β€” 7yr, vector DB + hybrid search infra, scale-focused
4. The applied researcher β€” 5yr, MSc/PhD but in industry, eval frameworks, A/B testing mindset
5. The product-ML hybrid β€” 6yr, ex-PM turned engineer, retrieval + ranking + product instincts

For each profile, write:
- headline (1 line)
- summary (3-4 sentences, natural language, no buzzwords)
- 2-3 job roles with title, company type, duration, description (50-100 words each)
- 5-7 skills with realistic experience durations

JOB DESCRIPTION:
{jd_text}

Return JSON array of profiles.
"""

Prompt for anti-persona resumes (our novel contribution):

ANTI_PERSONA_PROMPT = """
Generate {n} profiles that would seem relevant on surface but are explicitly
disqualified by the following job description.

The JD explicitly says these are NOT wanted:
1. Keyword stuffer β€” Marketing Manager with every AI keyword in skills, but career is marketing
2. Pure researcher β€” academic lab career, never shipped to production users
3. Consulting lifer β€” entire career at TCS/Infosys/Wipro/Accenture, no product company
4. Framework enthusiast β€” only LangChain/OpenAI wrapper projects, no pre-LLM ML experience
5. Title chaser β€” avg tenure <18 months across 4+ jobs, optimizing for "Senior" β†’ "Staff"

Each profile should look superficially plausible but fail the actual JD requirements.

JOB DESCRIPTION:
{jd_text}

Return JSON array of profiles.
"""

Final query vectors:

  • Embed each ideal+anti-persona resume as chunks
  • jd_query_vectors.npy β€” shape (n_ideals + n_anti, 384) with metadata flag (positive/negative)
  • At retrieval time: sim_positive = max(cosine(candidate, ideals)), sim_negative = max(cosine(candidate, anti_personas)), semantic_score = sim_positive - 0.4 * sim_negative

5.3 Stratified Sampler (stratified_sampler.py)

Claude teacher labels are expensive (API cost) and take time. Sample smartly. Want coverage across the full relevance spectrum, including hard cases.

def stratified_sample(candidates, embeddings, n=2500):
    strata = {
        "top_retrieval_bm25":    200,  # top BM25 hits β€” high relevance likely
        "top_retrieval_dense":   200,  # top dense hits β€” captures plain-language Tier 5s
        "top_anti_persona_sim":  150,  # high sim to anti-personas β€” keyword stuffers
        "title_match_strong":    200,  # current_title contains engineer/ML/AI
        "title_mismatch":        150,  # high skill-match, wrong title (stuffer detection)
        "consulting_only":       100,  # all career at big 5 IT services
        "honeypot_flagged":      100,  # caught by consistency engine
        "high_behavioral":       150,  # top redrob_signals scores
        "low_behavioral":        150,  # poor behavioral signals, maybe good profile
        "tier1_education":       100,  # IIT/IIM/NIT tier_1 candidates
        "random":                1000, # uniform random for distributional coverage
    }
    # Returns list of (candidate_id, stratum_label)

5.4 Teacher Labeling (teacher_label.py)

Model: Claude (via Anthropic API). claude-sonnet-4-6 or claude-haiku-4-5-20251001 for cost efficiency. Haiku is ~20x cheaper and sufficient for labeling.

Malt's two techniques (both required for label quality):

  1. Semantic rubric anchoring β€” fixed scale baked into the prompt so scores mean the same thing across all batches:
0.0 β€” No relevant skills or experience. Completely unable to perform the job.
0.2 β€” Minor relevance. Some adjacent skills but fundamentally wrong profile.
0.4 β€” Moderate match. Some relevant skills, significant gaps on core requirements.
0.6 β€” Good match. Mostly relevant, can perform with some ramp-up. Meets most requirements.
0.8 β€” Strong match. Highly relevant skills and experience. Ready to perform well.
1.0 β€” Perfect match. Skills and experience fully aligned. Expert on the topic.
  1. Anchored batching β€” always include 1 obvious 0.0 and 1 obvious 1.0 as anchors in every batch of 12 candidates. Forces consistent calibration across batches.

Batch prompt structure:

TEACHER_PROMPT = """
You are an objective evaluator for a recruiting platform.

JOB DESCRIPTION:
{jd_text}

SCORING RUBRIC (use ONLY these values):
0.0 | 0.2 | 0.4 | 0.6 | 0.8 | 1.0
{rubric_text}

Below are {n} candidate profiles. Score each independently.
Profile 1 is a known PERFECT FIT (score must be 0.9-1.0).
Profile {n} is a known NON-FIT (score must be 0.0-0.1).
Score profiles 2 through {n-1} based solely on the rubric.

For each candidate, provide:
- score: float (0.0, 0.2, 0.4, 0.6, 0.8, or 1.0)
- rationale: 1 sentence citing specific evidence from their profile

Return JSON array with fields: candidate_id, score, rationale

CANDIDATES:
{candidate_profiles_json}
"""

What to include per candidate for the teacher (keep minimal to save tokens):

def candidate_for_teacher(c: dict) -> dict:
    return {
        "candidate_id": c["candidate_id"],
        "current_title": c["profile"]["current_title"],
        "years_of_experience": c["profile"]["years_of_experience"],
        "summary": c["profile"]["summary"][:400],  # truncated
        "career": [
            {
                "title": j["title"],
                "company": j["company"],
                "industry": j["industry"],
                "company_size": j["company_size"],
                "duration_months": j["duration_months"],
                "description": j["description"][:200]
            }
            for j in c["career_history"][:4]
        ],
        "skills_top5": [
            {"name": s["name"], "proficiency": s["proficiency"],
             "endorsements": s["endorsements"], "duration_months": s.get("duration_months", 0)}
            for s in sorted(c.get("skills", []),
                           key=lambda x: x["endorsements"], reverse=True)[:5]
        ],
        "education": [
            {"degree": e["degree"], "field": e["field_of_study"],
             "institution": e["institution"], "tier": e.get("tier")}
            for e in c.get("education", [])[:2]
        ]
    }

Save teacher_labels.csv with columns: candidate_id, score, rationale, stratum.

Quality check before training: compute self-consistency by double-labeling 100 candidates with a fresh prompt. If Pearson correlation of scores < 0.85, the rubric needs tightening.

5.5 Train LambdaMART (train_ranker.py)

Why LambdaMART: Directly optimizes NDCG (the competition metric). LinkedIn production talent search uses LTR with embedding features. XGBoost rank:ndcg is the standard implementation.

import xgboost as xgb

dtrain = xgb.DMatrix(X_train, label=y_train)
dtrain.set_group(group_sizes_train)  # required for LTR

params = {
    "objective": "rank:ndcg",
    "eval_metric": "ndcg@10",
    "eta": 0.05,
    "max_depth": 6,
    "min_child_weight": 5,
    "subsample": 0.8,
    "colsample_bytree": 0.8,
    "n_estimators": 500,
    "tree_method": "hist",
    # Monotonic constraints: behavioral signals should have monotone positive effect
    # Feature index must match column order in X
    "monotone_constraints": "(0,0,0,...,1,1,1,...)",  # fill after feature list is finalized
}

model = xgb.train(params, dtrain, evals=[(dval, "val")], early_stopping_rounds=30)
model.save_model("artifacts/ranker_model.json")

Hold-out eval: use 20% of teacher-labeled candidates as validation set. Compute NDCG@10 locally. Run ablations (no behavioral signals, no anti-persona, etc.) β€” this directly becomes your Stage 4 methodology and Stage 5 interview material.