Spaces:
Sleeping
Sleeping
optimus commited on
Deploy Redrob Streamlit sandbox
Browse files- README.md +36 -13
- app.py +0 -0
- redrob/__init__.py +2 -0
- redrob/blueprint.py +56 -0
- redrob/config.py +413 -0
- redrob/data.py +295 -0
- redrob/features/__init__.py +13 -0
- redrob/features/behavioral.py +111 -0
- redrob/features/honeypot.py +253 -0
- redrob/features/skill_features.py +237 -0
- redrob/features/title_features.py +148 -0
- redrob/graph/__init__.py +13 -0
- redrob/graph/build.py +115 -0
- redrob/graph/coherence.py +246 -0
- redrob/graph/propagate.py +128 -0
- redrob/rank/__init__.py +16 -0
- redrob/rank/predict.py +134 -0
- redrob/rank/train.py +636 -0
- redrob/reasoning/__init__.py +9 -0
- redrob/reasoning/template.py +598 -0
- redrob/retrieval/__init__.py +10 -0
- redrob/retrieval/bm25.py +83 -0
- redrob/retrieval/dense.py +139 -0
- redrob/retrieval/rrf.py +45 -0
- redrob/submit/__init__.py +4 -0
- redrob/submit/write.py +91 -0
- requirements.txt +11 -3
- scripts/run_ranking.py +317 -0
README.md
CHANGED
|
@@ -1,20 +1,43 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji: 🚀
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
- streamlit
|
| 10 |
pinned: false
|
| 11 |
-
short_description: Streamlit template space
|
| 12 |
license: mit
|
| 13 |
---
|
| 14 |
|
| 15 |
-
#
|
| 16 |
|
| 17 |
-
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Redrob Candidate Intelligence Engine
|
| 3 |
+
emoji: "🚀"
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: streamlit
|
| 7 |
+
sdk_version: 1.30.0
|
| 8 |
+
app_file: app.py
|
|
|
|
| 9 |
pinned: false
|
|
|
|
| 10 |
license: mit
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# Redrob Candidate Intelligence — Stage 1 Sandbox
|
| 14 |
|
| 15 |
+
This Space hosts the **Stage 1 sandbox** for the Redrob hackathon
|
| 16 |
+
submission (Stage-1 requirement per submission spec Section 10.5).
|
| 17 |
|
| 18 |
+
The Streamlit app accepts a candidate sample (≤5,000 records) uploaded
|
| 19 |
+
by the user, runs the **complete pipeline end-to-end** (BM25 + 5-axis
|
| 20 |
+
PageRank + 74 features + deterministic composite + reasoning), and
|
| 21 |
+
produces a downloadable ranked CSV.
|
| 22 |
+
|
| 23 |
+
For the **full 100k run**, the official reproduce command is:
|
| 24 |
+
```bash
|
| 25 |
+
python scripts/run_ranking.py --no_dense --no_lgbm
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
## What's in this Space
|
| 29 |
+
|
| 30 |
+
- `app.py` — Streamlit sandbox UI
|
| 31 |
+
- `redrob/` — full pipeline package (config, blueprint, retrieval,
|
| 32 |
+
graph, features, rank, reasoning, submit)
|
| 33 |
+
- `scripts/run_ranking.py` — the official pipeline (re-use from app.py)
|
| 34 |
+
- `requirements.txt` — Python dependencies
|
| 35 |
+
|
| 36 |
+
## Source repository
|
| 37 |
+
|
| 38 |
+
The complete submission (top-100 ranked CSV, full source code, all
|
| 39 |
+
experiments) is at:
|
| 40 |
+
**https://github.com/AdityaProCoder/JobRanker**
|
| 41 |
+
|
| 42 |
+
`submission.csv` (top-100 ranked) and `submission_metadata.yaml` are
|
| 43 |
+
in the GitHub repo.
|
app.py
ADDED
|
Binary file (24.5 kB). View file
|
|
|
redrob/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Redrob Candidate Intelligence Engine — top-level package."""
|
| 2 |
+
__version__ = "1.0.0"
|
redrob/blueprint.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Role Blueprint extraction.
|
| 2 |
+
|
| 3 |
+
The blueprint is largely static (already curated in `config.py`) but the
|
| 4 |
+
public entry point also writes `artifacts/blueprint.json` for the sandbox
|
| 5 |
+
to display.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
from typing import Any, Dict, List
|
| 11 |
+
|
| 12 |
+
from . import config
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def build_blueprint() -> Dict[str, Any]:
|
| 16 |
+
"""Return the role blueprint as a plain dict (also JSON-serializable)."""
|
| 17 |
+
bp: Dict[str, Any] = {
|
| 18 |
+
"role": "Senior AI Engineer — Redrob AI (Series A)",
|
| 19 |
+
"company": "Redrob AI",
|
| 20 |
+
"location_preference": "Pune / Noida (Hybrid)",
|
| 21 |
+
"country": "India",
|
| 22 |
+
"experience_band": {
|
| 23 |
+
"min": config.YOE_MIN,
|
| 24 |
+
"ideal_low": config.YOE_IDEAL_LOW,
|
| 25 |
+
"ideal_high": config.YOE_IDEAL_HIGH,
|
| 26 |
+
"max_useful": config.YOE_MAX_USEFUL,
|
| 27 |
+
},
|
| 28 |
+
"core_competencies": sorted(config.CORE_COMPETENCIES),
|
| 29 |
+
"adjacent_competencies": sorted(config.ADJACENT_COMPETENCIES),
|
| 30 |
+
"negative_competencies": sorted(config.NEGATIVE_COMPETENCIES),
|
| 31 |
+
"target_titles": sorted(config.TARGET_TITLES),
|
| 32 |
+
"title_role_groups": config.TITLE_ROLE_GROUPS,
|
| 33 |
+
"title_group_weight": config.TITLE_GROUP_WEIGHT,
|
| 34 |
+
"notice_period_ok_days": config.NOTICE_OK_DAYS,
|
| 35 |
+
"notice_period_hard_days": config.NOTICE_HARD_DAYS,
|
| 36 |
+
"preferred_locations": sorted(config.PREFERRED_LOCATIONS),
|
| 37 |
+
"country_ok": sorted(config.COUNTRY_OK),
|
| 38 |
+
"it_services_pure_play": sorted(config.IT_SERVICES_PURE_PLAY),
|
| 39 |
+
"product_company_hints": sorted(config.PRODUCT_COMPANY_HINTS),
|
| 40 |
+
"query_terms": list(config.JD_QUERY_TERMS),
|
| 41 |
+
"structured_gate_terms": sorted(config.STRUCTURED_GATE_TERMS),
|
| 42 |
+
}
|
| 43 |
+
return bp
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def save_blueprint(path=None) -> Dict[str, Any]:
|
| 47 |
+
bp = build_blueprint()
|
| 48 |
+
out = config.BLUEPRINT_JSON if path is None else path
|
| 49 |
+
with open(out, "w", encoding="utf-8") as f:
|
| 50 |
+
json.dump(bp, f, indent=2, ensure_ascii=False)
|
| 51 |
+
return bp
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
if __name__ == "__main__":
|
| 55 |
+
save_blueprint()
|
| 56 |
+
print(f"blueprint -> {config.BLUEPRINT_JSON}")
|
redrob/config.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Project-wide configuration.
|
| 2 |
+
|
| 3 |
+
Paths, JD blueprint, and tuning knobs. Editable from a single place.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Dict, List, Set
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# ---------------------------------------------------------------------------
|
| 12 |
+
# Paths
|
| 13 |
+
# ---------------------------------------------------------------------------
|
| 14 |
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
| 15 |
+
ARTIFACTS_DIR = PROJECT_ROOT / "artifacts"
|
| 16 |
+
MODELS_DIR = ARTIFACTS_DIR / "models"
|
| 17 |
+
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
|
| 18 |
+
MODELS_DIR.mkdir(parents=True, exist_ok=True)
|
| 19 |
+
|
| 20 |
+
DEFAULT_CANDIDATES_JSONL = (
|
| 21 |
+
PROJECT_ROOT
|
| 22 |
+
/ "[PUB] India_runs_data_and_ai_challenge"
|
| 23 |
+
/ "[PUB] India_runs_data_and_ai_challenge"
|
| 24 |
+
/ "India_runs_data_and_ai_challenge"
|
| 25 |
+
/ "candidates.jsonl"
|
| 26 |
+
)
|
| 27 |
+
DEFAULT_JOB_DESCRIPTION = (
|
| 28 |
+
PROJECT_ROOT
|
| 29 |
+
/ "[PUB] India_runs_data_and_ai_challenge"
|
| 30 |
+
/ "[PUB] India_runs_data_and_ai_challenge"
|
| 31 |
+
/ "India_runs_data_and_ai_challenge"
|
| 32 |
+
/ "job_description.docx"
|
| 33 |
+
)
|
| 34 |
+
DEFAULT_OUTPUT_CSV = PROJECT_ROOT / "submission.csv"
|
| 35 |
+
DEFAULT_VALIDATOR = (
|
| 36 |
+
PROJECT_ROOT
|
| 37 |
+
/ "[PUB] India_runs_data_and_ai_challenge"
|
| 38 |
+
/ "[PUB] India_runs_data_and_ai_challenge"
|
| 39 |
+
/ "India_runs_data_and_ai_challenge"
|
| 40 |
+
/ "validate_submission.py"
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# Artifact filenames
|
| 44 |
+
CANDIDATES_PARQUET = ARTIFACTS_DIR / "candidates.parquet"
|
| 45 |
+
GRAPH_PICKLE = ARTIFACTS_DIR / "graph.pkl"
|
| 46 |
+
TITLE_EMB_NPY = ARTIFACTS_DIR / "title_embeddings.npy"
|
| 47 |
+
BM25_PICKLE = ARTIFACTS_DIR / "bm25_index.pkl"
|
| 48 |
+
BM25_TOKENS_PICKLE = ARTIFACTS_DIR / "bm25_tokens.pkl"
|
| 49 |
+
DENSE_NPY = ARTIFACTS_DIR / "dense_index.npy"
|
| 50 |
+
DENSE_IDS_JSON = ARTIFACTS_DIR / "dense_ids.json"
|
| 51 |
+
RANKER_TXT = ARTIFACTS_DIR / "ranker.txt"
|
| 52 |
+
BLUEPRINT_JSON = ARTIFACTS_DIR / "blueprint.json"
|
| 53 |
+
|
| 54 |
+
# ---------------------------------------------------------------------------
|
| 55 |
+
# Role Blueprint — Senior AI Engineer (Redrob founding team)
|
| 56 |
+
# Extracted from job_description.docx. Kept here in code so the sandbox
|
| 57 |
+
# can run without parsing the .docx and so the blueprint is auditable.
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
|
| 60 |
+
CORE_COMPETENCIES: Set[str] = {
|
| 61 |
+
# ML foundations
|
| 62 |
+
"PyTorch", "TensorFlow", "Transformers", "Hugging Face", "HuggingFace",
|
| 63 |
+
"LLM", "LLMs", "Large Language Models", "Fine-tuning", "Fine-Tuning",
|
| 64 |
+
"LoRA", "QLoRA", "PEFT", "Prompt Engineering",
|
| 65 |
+
# Retrieval / ranking infra
|
| 66 |
+
"RAG", "Retrieval Augmented Generation", "Vector Database", "Vector Databases",
|
| 67 |
+
"Pinecone", "Weaviate", "Qdrant", "Milvus", "FAISS", "OpenSearch",
|
| 68 |
+
"Elasticsearch", "BM25", "Hybrid Search", "Dense Retrieval",
|
| 69 |
+
"Sentence Transformers", "sentence-transformers", "BGE", "E5",
|
| 70 |
+
"Embeddings", "Semantic Search",
|
| 71 |
+
# IR / ranking theory
|
| 72 |
+
"Learning to Rank", "Learning-to-Rank", "Learning2Rank", "LTR",
|
| 73 |
+
"XGBoost", "LightGBM", "CatBoost", "NDCG", "MRR", "MAP",
|
| 74 |
+
"LambdaRank", "LambdaMART",
|
| 75 |
+
# Engineering
|
| 76 |
+
"Python", "AWS", "GCP", "Azure", "Docker", "Kubernetes",
|
| 77 |
+
"Distributed Systems", "MLOps", "ML Ops", "Inference",
|
| 78 |
+
# Eval / experimentation
|
| 79 |
+
"A/B Testing", "A/B Test", "Experimentation", "Evaluation",
|
| 80 |
+
"Offline Evaluation", "Online Evaluation", "Eval Framework", "Evaluation Framework",
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
ADJACENT_COMPETENCIES: Set[str] = {
|
| 84 |
+
"Spark", "PySpark", "Airflow", "Kafka", "dbt", "Snowflake",
|
| 85 |
+
"Databricks", "BigQuery", "Redshift", "Beam", "Flink",
|
| 86 |
+
"Feature Store", "Feature Engineering", "MLflow", "Kubeflow",
|
| 87 |
+
"Triton", "TensorRT", "ONNX", "RecSys", "Recommendation",
|
| 88 |
+
"Recommendation Systems", "Search", "Ranking", "Personalization",
|
| 89 |
+
"Open Source", "Open-Source", "GitHub", "NLP", "Computer Vision",
|
| 90 |
+
"LangChain", "LlamaIndex", "LangGraph",
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
NEGATIVE_COMPETENCIES: Set[str] = {
|
| 94 |
+
# these are not disqualifiers by themselves — used to detect
|
| 95 |
+
# "Marketing Manager + AI keywords" profiles during honeypot scoring
|
| 96 |
+
"SEO", "Photoshop", "Illustrator", "Content Writing", "Sales",
|
| 97 |
+
"Marketing", "Accounting", "Mechanical Design", "Civil Engineering",
|
| 98 |
+
"Customer Support", "Operations Management", "HR Management",
|
| 99 |
+
"Business Analysis", "Six Sigma", "SAP", "Recruiting",
|
| 100 |
+
"Brand Design", "Talent Acquisition", "CAD", "SolidWorks",
|
| 101 |
+
"ANSYS", "FEA", "DFM", "DFMA",
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
# Phrases that the structured high-recall gate uses to keep adjacent roles
|
| 105 |
+
# like Search/Relevance Engineers in the pool.
|
| 106 |
+
STRUCTURED_GATE_TERMS: Set[str] = {
|
| 107 |
+
"pytorch", "tensorflow", "hugging", "transformers", "llm", "rag",
|
| 108 |
+
"retrieval", "recommendation", "ranking", "ranker", "search", "embedding",
|
| 109 |
+
"vector", "faiss", "pinecone", "weaviate", "qdrant", "milvus",
|
| 110 |
+
"opensearch", "elasticsearch", "bm25", "xgboost", "lightgbm", "catboost",
|
| 111 |
+
"learning to rank", "learning-to-rank", "learning2rank", "lambdarank",
|
| 112 |
+
"lora", "qlora", "peft", "fine-tuning", "fine tuning", "fine-tune",
|
| 113 |
+
"sentence-transformer", "sentence transformer", "bge", "e5",
|
| 114 |
+
"ml engineer", "ai engineer", "applied scientist", "applied ml",
|
| 115 |
+
"machine learning", "data scientist", "nlp engineer", "nlp",
|
| 116 |
+
"search engineer", "search relevance", "relevance engineer",
|
| 117 |
+
"recommender", "recommendation systems", "ranker", "rank model",
|
| 118 |
+
"feature store", "feature engineering", "mlops", "kubeflow", "mlflow",
|
| 119 |
+
"triton", "tensorrt", "onnx", "distributed training", "inference",
|
| 120 |
+
"hybrid search", "dense retrieval", "sparse retrieval",
|
| 121 |
+
"promotion velocity", "reranking", "re-ranking", "rerank", "re-rank",
|
| 122 |
+
"vector database", "vector search",
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
TARGET_TITLES: Set[str] = {
|
| 126 |
+
"ML Engineer", "Senior ML Engineer", "Staff ML Engineer", "Principal ML Engineer",
|
| 127 |
+
"Machine Learning Engineer", "Senior Machine Learning Engineer",
|
| 128 |
+
"AI Engineer", "Senior AI Engineer", "Staff AI Engineer",
|
| 129 |
+
"Applied Scientist", "Senior Applied Scientist", "Staff Applied Scientist",
|
| 130 |
+
"Applied ML Engineer", "Senior Applied ML Engineer",
|
| 131 |
+
"Research Engineer", "Senior Research Engineer",
|
| 132 |
+
"Recommendation Systems Engineer", "Search Engineer", "Search Relevance Engineer",
|
| 133 |
+
"Relevance Engineer", "Ranking Engineer", "Senior Ranking Engineer",
|
| 134 |
+
"NLP Engineer", "Senior NLP Engineer",
|
| 135 |
+
"Data Scientist", "Senior Data Scientist", "Staff Data Scientist",
|
| 136 |
+
"Machine Learning Scientist", "Senior Machine Learning Scientist",
|
| 137 |
+
"AI Specialist", "Senior AI Specialist",
|
| 138 |
+
"Founding Engineer", "Founding ML Engineer",
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
# Title similarity groups — used for the graduated title fit feature.
|
| 142 |
+
# Each group has a target role weight; matches accumulate.
|
| 143 |
+
TITLE_ROLE_GROUPS: Dict[str, List[str]] = {
|
| 144 |
+
"applied_ml": [
|
| 145 |
+
"ml engineer", "machine learning engineer", "ai engineer",
|
| 146 |
+
"applied scientist", "applied ml", "research engineer",
|
| 147 |
+
"machine learning scientist", "ai specialist",
|
| 148 |
+
],
|
| 149 |
+
"retrieval_ranking": [
|
| 150 |
+
"search engineer", "search relevance engineer", "relevance engineer",
|
| 151 |
+
"ranking engineer", "recommendation systems engineer",
|
| 152 |
+
"recommender", "recommendation systems",
|
| 153 |
+
],
|
| 154 |
+
"nlp_llm": [
|
| 155 |
+
"nlp engineer", "nlp", "llm engineer", "generative ai engineer",
|
| 156 |
+
],
|
| 157 |
+
"data_science": [
|
| 158 |
+
"data scientist", "senior data scientist", "applied data scientist",
|
| 159 |
+
],
|
| 160 |
+
"data_platform": [
|
| 161 |
+
"data engineer", "senior data engineer", "analytics engineer",
|
| 162 |
+
"ml platform engineer", "ml infrastructure engineer", "mlops engineer",
|
| 163 |
+
],
|
| 164 |
+
"generic_swe": [
|
| 165 |
+
"software engineer", "senior software engineer", "backend engineer",
|
| 166 |
+
"full stack developer", "full stack engineer", "platform engineer",
|
| 167 |
+
],
|
| 168 |
+
"non_target": [
|
| 169 |
+
"marketing manager", "hr manager", "accountant", "sales executive",
|
| 170 |
+
"business analyst", "operations manager", "project manager",
|
| 171 |
+
"graphic designer", "content writer", "civil engineer",
|
| 172 |
+
"mechanical engineer", "customer support", "qa engineer",
|
| 173 |
+
"frontend engineer", "frontend developer", "java developer",
|
| 174 |
+
".net developer", "mobile developer", "devops engineer",
|
| 175 |
+
"cloud engineer", "data analyst", "frontend",
|
| 176 |
+
],
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
# Title weights used by the graduated title fit scorer (target role weight).
|
| 180 |
+
# Updated based on data analysis: Senior Data Scientist with strong JD-core
|
| 181 |
+
# skills (BM25, FAISS, NDCG) at Microsoft/Google is a great fit. Boosted
|
| 182 |
+
# data_science weight from 0.70 to 0.85. Also added product_company tier
|
| 183 |
+
# boosts to capture additional strong-elite candidates.
|
| 184 |
+
TITLE_GROUP_WEIGHT: Dict[str, float] = {
|
| 185 |
+
"applied_ml": 1.00,
|
| 186 |
+
"retrieval_ranking": 0.95,
|
| 187 |
+
"nlp_llm": 0.90,
|
| 188 |
+
"data_science": 0.85, # boosted: Sr DS w/ strong JD-core skills is a great fit
|
| 189 |
+
"data_platform": 0.60,
|
| 190 |
+
"generic_swe": 0.45,
|
| 191 |
+
"non_target": 0.05,
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
# Seniority band (years of experience).
|
| 195 |
+
YOE_MIN = 4.0
|
| 196 |
+
YOE_IDEAL_LOW = 5.0
|
| 197 |
+
YOE_IDEAL_HIGH = 9.0
|
| 198 |
+
YOE_MAX_USEFUL = 15.0
|
| 199 |
+
|
| 200 |
+
# Notice period & location
|
| 201 |
+
NOTICE_OK_DAYS = 30
|
| 202 |
+
NOTICE_HARD_DAYS = 90
|
| 203 |
+
PREFERRED_LOCATIONS = {
|
| 204 |
+
"Noida", "Pune", "Hyderabad", "Mumbai", "Bangalore", "Bengaluru",
|
| 205 |
+
"Delhi", "Gurgaon", "Gurugram", "Delhi NCR",
|
| 206 |
+
}
|
| 207 |
+
COUNTRY_OK = {"India"}
|
| 208 |
+
|
| 209 |
+
# Companies whose culture the JD flags ("title-chasers", "framework enthusiasts",
|
| 210 |
+
# pure-services). Not strict exclusions but used as a soft penalty in features.
|
| 211 |
+
IT_SERVICES_PURE_PLAY = {
|
| 212 |
+
"TCS", "Infosys", "Wipro", "HCL", "Tech Mahindra", "Cognizant",
|
| 213 |
+
"Capgemini", "Accenture", "Mindtree", "L&T Infotech", "Mphasis",
|
| 214 |
+
"Persistent", "Zensar", "Hexaware", "Birlasoft", "Cyient",
|
| 215 |
+
}
|
| 216 |
+
PRODUCT_COMPANY_HINTS = {
|
| 217 |
+
"Razorpay", "Zerodha", "CRED", "PhonePe", "Paytm", "Swiggy",
|
| 218 |
+
"Zomato", "Ola", "Flipkart", "Meesho", "Urban Company",
|
| 219 |
+
"Dream11", "Groww", "Cars24", "MPL", "Lenskart", "Nykaa",
|
| 220 |
+
"Postman", "Freshworks", "Zoho", "Chargebee", "Razorpay",
|
| 221 |
+
"BrowserStack", "Sprinklr", "CleverTap", "MoEngage", "ShareChat",
|
| 222 |
+
"InMobi", "Oyo", "OYO", "Rivigo", "Delhivery", "Udaan",
|
| 223 |
+
"Microsoft", "Google", "Meta", "Amazon", "Apple", "Netflix",
|
| 224 |
+
"Stripe", "Airbnb", "Uber", "Linkedin", "LinkedIn",
|
| 225 |
+
"Salesforce", "Adobe", "Nvidia", "NVIDIA", "Intel", "IBM",
|
| 226 |
+
"Oracle", "SAP", "ServiceNow", "Snowflake", "Databricks",
|
| 227 |
+
"Redrob", "Redrob AI", "RedrobAI",
|
| 228 |
+
"OpenAI", "Anthropic", "Cohere", "Hugging Face", "HuggingFace",
|
| 229 |
+
"Pinecone", "Weaviate", "Qdrant",
|
| 230 |
+
# Indian AI-first labs / platforms (extended coverage)
|
| 231 |
+
"Sarvam", "Sarvam AI", "Niramai", "Rephrase", "Fractal",
|
| 232 |
+
"Yellow.ai", "Mad Street Den", "Wysa", "Haptik",
|
| 233 |
+
"Vernacular.ai", "Agnatha", "Aganitha", "Uniphore", "Observe.AI",
|
| 234 |
+
"Turing", "Turing.com", "Locobuzz", "Verloop", "Verloop.io",
|
| 235 |
+
"Bobble", "Bobble AI", "Galytix", "Locus", "Locus.sh",
|
| 236 |
+
"Conv.AI", "Yellow Messenger", "Krutrim",
|
| 237 |
+
# Ed-tech / health-tech (product companies with AI teams)
|
| 238 |
+
"BYJU", "BYJUS", "BYJU'S", "Unacademy", "Vedantu", "upGrad",
|
| 239 |
+
"upGrad", "Simplilearn", "Great Learning", "Eruditus",
|
| 240 |
+
"PolicyBazaar", "PolicyBazaar.com", "Lendingkart",
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
# ---------------------------------------------------------------------------
|
| 244 |
+
# Retrieval knobs
|
| 245 |
+
# ---------------------------------------------------------------------------
|
| 246 |
+
|
| 247 |
+
BM25_TOP_K = 3000
|
| 248 |
+
DENSE_TOP_K = 3000
|
| 249 |
+
STRUCTURED_GATE_TOP_K = 8000 # recall-friendly; not a hard filter
|
| 250 |
+
RRF_K = 60
|
| 251 |
+
SHORTLIST_N = 4000
|
| 252 |
+
|
| 253 |
+
# DENSE_MODEL can be overridden via env var REDROB_DENSE_MODEL
|
| 254 |
+
# Default uses MiniLM for fast offline runs; stronger BGE models are
|
| 255 |
+
# supported by setting the env var before running.
|
| 256 |
+
DEFAULT_DENSE_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
|
| 257 |
+
# Stronger alternatives (require the model to be cached locally):
|
| 258 |
+
# "BAAI/bge-small-en-v1.5"
|
| 259 |
+
# "BAAI/bge-base-en-v1.5"
|
| 260 |
+
# "BAAI/bge-large-en-v1.5"
|
| 261 |
+
DENSE_MODEL_OPTIONS = [
|
| 262 |
+
DEFAULT_DENSE_MODEL,
|
| 263 |
+
"BAAI/bge-small-en-v1.5",
|
| 264 |
+
"BAAI/bge-base-en-v1.5",
|
| 265 |
+
"BAAI/bge-large-en-v1.5",
|
| 266 |
+
]
|
| 267 |
+
|
| 268 |
+
# BM25 / dense query
|
| 269 |
+
JD_QUERY_TERMS = [
|
| 270 |
+
"Senior AI Engineer",
|
| 271 |
+
"Applied ML Engineer",
|
| 272 |
+
"retrieval augmented generation",
|
| 273 |
+
"vector search",
|
| 274 |
+
"hybrid search",
|
| 275 |
+
"BM25 FAISS Pinecone",
|
| 276 |
+
"PyTorch transformers",
|
| 277 |
+
"fine-tuning LoRA QLoRA PEFT",
|
| 278 |
+
"learning to rank XGBoost LightGBM",
|
| 279 |
+
"NDCG MRR MAP",
|
| 280 |
+
"embeddings sentence-transformers BGE E5",
|
| 281 |
+
"vector database Milvus Qdrant Weaviate",
|
| 282 |
+
"production machine learning systems",
|
| 283 |
+
"evaluation framework A/B testing",
|
| 284 |
+
"ranker ranking recommendation",
|
| 285 |
+
"MLOps inference distributed",
|
| 286 |
+
]
|
| 287 |
+
|
| 288 |
+
# ---------------------------------------------------------------------------
|
| 289 |
+
# Ranker knobs
|
| 290 |
+
# ---------------------------------------------------------------------------
|
| 291 |
+
|
| 292 |
+
RANKER_PARAMS = dict(
|
| 293 |
+
objective="lambdarank",
|
| 294 |
+
metric="ndcg",
|
| 295 |
+
eval_at=[10, 50],
|
| 296 |
+
num_leaves=63,
|
| 297 |
+
learning_rate=0.05,
|
| 298 |
+
n_estimators=600,
|
| 299 |
+
lambdarank_truncation_level=50,
|
| 300 |
+
min_data_in_leaf=20,
|
| 301 |
+
feature_fraction=0.85,
|
| 302 |
+
bagging_fraction=0.85,
|
| 303 |
+
bagging_freq=5,
|
| 304 |
+
random_state=42,
|
| 305 |
+
verbose=-1,
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
# Hard exclude honeypots at/above this penalty.
|
| 309 |
+
HONEYPOT_HARD_EXCLUDE = 0.5
|
| 310 |
+
|
| 311 |
+
# Reasoning template rotation
|
| 312 |
+
REASONING_TEMPLATE_COUNT = 15
|
| 313 |
+
|
| 314 |
+
# ---------------------------------------------------------------------------
|
| 315 |
+
# JD-criticality weighting (for skill feature engineering)
|
| 316 |
+
# ---------------------------------------------------------------------------
|
| 317 |
+
|
| 318 |
+
JD_CRITICAL: set[str] = {
|
| 319 |
+
# The JD's "absolutely need" — weight 2.0
|
| 320 |
+
"RAG", "Retrieval Augmented Generation",
|
| 321 |
+
"FAISS", "Pinecone", "Weaviate", "Qdrant", "Milvus",
|
| 322 |
+
"OpenSearch", "Elasticsearch",
|
| 323 |
+
"BM25", "Hybrid Search", "Dense Retrieval",
|
| 324 |
+
"Sentence Transformers", "BGE", "E5", "Embeddings",
|
| 325 |
+
"Learning to Rank", "LambdaRank", "NDCG", "MRR", "MAP",
|
| 326 |
+
"XGBoost", "LightGBM", "CatBoost",
|
| 327 |
+
"PyTorch", "Transformers", "LLM", "Fine-tuning", "LoRA", "QLoRA", "PEFT",
|
| 328 |
+
"Prompt Engineering", "Hugging Face", "HuggingFace",
|
| 329 |
+
"Vector Database", "Vector Databases",
|
| 330 |
+
}
|
| 331 |
+
JD_NICE_TO_HAVE: set[str] = {
|
| 332 |
+
# JD "nice to have" — weight 1.0
|
| 333 |
+
"Kubernetes", "Docker", "AWS", "GCP", "Azure", "Python",
|
| 334 |
+
"MLOps", "ML Ops", "MLflow", "Kubeflow",
|
| 335 |
+
"Triton", "TensorRT", "ONNX",
|
| 336 |
+
"Distributed Systems", "Inference", "Evaluation",
|
| 337 |
+
"A/B Testing", "A/B Test", "Experimentation",
|
| 338 |
+
"NLP", "Computer Vision",
|
| 339 |
+
"Recommendation", "Recommendation Systems", "Search", "Ranking",
|
| 340 |
+
"RecSys", "LlamaIndex", "LangChain", "LangGraph",
|
| 341 |
+
"Open Source", "Open-Source",
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
# ---------------------------------------------------------------------------
|
| 345 |
+
# Multi-axis PPR: 5 JD axes
|
| 346 |
+
# ---------------------------------------------------------------------------
|
| 347 |
+
|
| 348 |
+
JD_AXES: dict[str, list[str]] = {
|
| 349 |
+
"applied_ml": [
|
| 350 |
+
"PyTorch", "TensorFlow", "Transformers", "Hugging", "LLM", "LoRA",
|
| 351 |
+
"QLoRA", "PEFT", "Fine-tuning", "Fine-Tuning", "Prompt Engineering",
|
| 352 |
+
"NLP", "scikit-learn",
|
| 353 |
+
],
|
| 354 |
+
"retrieval_rank": [
|
| 355 |
+
"FAISS", "Pinecone", "Weaviate", "Qdrant", "Milvus", "OpenSearch",
|
| 356 |
+
"Elasticsearch", "BM25", "Hybrid Search", "Dense Retrieval",
|
| 357 |
+
"Learning to Rank", "LambdaRank", "NDCG", "MRR", "MAP",
|
| 358 |
+
"XGBoost", "LightGBM", "CatBoost",
|
| 359 |
+
"Sentence Transformers", "BGE", "E5", "Embeddings",
|
| 360 |
+
],
|
| 361 |
+
"nlp_llm": [
|
| 362 |
+
"LLM", "Fine-tuning", "LoRA", "QLoRA", "PEFT", "Prompt Engineering",
|
| 363 |
+
"RAG", "Retrieval Augmented Generation", "Vector Database",
|
| 364 |
+
"LangChain", "LlamaIndex", "LangGraph", "NLP", "Transformers",
|
| 365 |
+
],
|
| 366 |
+
"production_eng": [
|
| 367 |
+
"Kubernetes", "Docker", "AWS", "GCP", "Azure", "Distributed Systems",
|
| 368 |
+
"MLOps", "MLflow", "Kubeflow", "Triton", "TensorRT", "ONNX",
|
| 369 |
+
"Inference", "Evaluation",
|
| 370 |
+
],
|
| 371 |
+
"product_company": [
|
| 372 |
+
"Razorpay", "Zerodha", "CRED", "PhonePe", "Paytm", "Swiggy", "Zomato",
|
| 373 |
+
"Ola", "Flipkart", "Meesho", "Urban Company", "Dream11", "Groww",
|
| 374 |
+
"Cars24", "Postman", "Freshworks", "Zoho", "Chargebee", "BrowserStack",
|
| 375 |
+
"Sprinklr", "CleverTap", "MoEngage", "ShareChat", "InMobi",
|
| 376 |
+
"Microsoft", "Google", "Meta", "Amazon", "Apple", "Netflix",
|
| 377 |
+
"Stripe", "Airbnb", "Uber", "LinkedIn", "Salesforce", "Adobe",
|
| 378 |
+
"NVIDIA", "Snowflake", "Databricks",
|
| 379 |
+
"OpenAI", "Anthropic", "Cohere", "Hugging Face",
|
| 380 |
+
"Pinecone", "Weaviate", "Qdrant",
|
| 381 |
+
],
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
# ---------------------------------------------------------------------------
|
| 385 |
+
# Company tier (for company-quality feature)
|
| 386 |
+
# ---------------------------------------------------------------------------
|
| 387 |
+
|
| 388 |
+
COMPANY_TIER: dict[int, set[str]] = {
|
| 389 |
+
3: {
|
| 390 |
+
"Google", "Meta", "Microsoft", "Amazon", "Apple", "Netflix",
|
| 391 |
+
"Stripe", "Airbnb", "Uber", "LinkedIn",
|
| 392 |
+
"Salesforce", "Adobe", "NVIDIA", "Snowflake", "Databricks",
|
| 393 |
+
"OpenAI", "Anthropic", "Cohere", "Hugging Face",
|
| 394 |
+
"Pinecone", "Weaviate", "Qdrant",
|
| 395 |
+
},
|
| 396 |
+
2: {
|
| 397 |
+
"Razorpay", "Zerodha", "CRED", "PhonePe", "Paytm",
|
| 398 |
+
"Swiggy", "Zomato", "Flipkart", "Meesho", "Urban Company",
|
| 399 |
+
"Postman", "Freshworks", "Zoho", "Chargebee",
|
| 400 |
+
"BrowserStack", "Sprinklr", "CleverTap", "MoEngage", "ShareChat",
|
| 401 |
+
"InMobi", "Dream11", "Groww", "Cars24",
|
| 402 |
+
"Ola", "Rivigo", "Delhivery", "Udaan", "Oyo",
|
| 403 |
+
"Intel", "IBM", "Oracle", "Redrob",
|
| 404 |
+
# Indian AI-first labs
|
| 405 |
+
"Sarvam AI", "Niramai", "Rephrase", "Fractal", "Yellow.ai",
|
| 406 |
+
"Mad Street Den", "Wysa", "Haptik", "Uniphore", "Observe.AI",
|
| 407 |
+
"Turing", "Locobuzz", "Verloop", "Bobble AI", "Galytix",
|
| 408 |
+
"Locus", "Krutrim",
|
| 409 |
+
# Ed-tech / health-tech (product companies with AI teams)
|
| 410 |
+
"BYJU", "BYJUS", "Unacademy", "Vedantu", "upGrad",
|
| 411 |
+
"PolicyBazaar",
|
| 412 |
+
},
|
| 413 |
+
}
|
redrob/data.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Candidate canonicalisation.
|
| 2 |
+
|
| 3 |
+
Streams the 100k JSONL, normalises each record into a compact dict, and
|
| 4 |
+
optionally writes a parquet cache for fast reloads.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import re
|
| 10 |
+
from dataclasses import dataclass, field, asdict
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Dict, Iterator, List, Optional, Tuple
|
| 13 |
+
|
| 14 |
+
import pandas as pd
|
| 15 |
+
|
| 16 |
+
from . import config
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
# Canonical record
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class CanonicalCandidate:
|
| 25 |
+
candidate_id: str
|
| 26 |
+
name: str
|
| 27 |
+
headline: str
|
| 28 |
+
summary: str
|
| 29 |
+
location: str
|
| 30 |
+
country: str
|
| 31 |
+
yoe: float
|
| 32 |
+
current_title: str
|
| 33 |
+
current_company: str
|
| 34 |
+
current_company_size: str
|
| 35 |
+
current_industry: str
|
| 36 |
+
career: List[Dict] = field(default_factory=list)
|
| 37 |
+
edu: List[Dict] = field(default_factory=list)
|
| 38 |
+
skills: List[Dict] = field(default_factory=list)
|
| 39 |
+
certs: List[Dict] = field(default_factory=list)
|
| 40 |
+
languages: List[Dict] = field(default_factory=list)
|
| 41 |
+
signals: Dict = field(default_factory=dict)
|
| 42 |
+
# Derived
|
| 43 |
+
text_corpus: str = ""
|
| 44 |
+
career_total_months: int = 0
|
| 45 |
+
career_overlap_months: int = 0
|
| 46 |
+
career_industries: List[str] = field(default_factory=list)
|
| 47 |
+
career_titles: List[str] = field(default_factory=list)
|
| 48 |
+
career_companies: List[str] = field(default_factory=list)
|
| 49 |
+
skill_names: List[str] = field(default_factory=list)
|
| 50 |
+
skill_durations_max: Dict[str, int] = field(default_factory=dict)
|
| 51 |
+
has_ai_skill: int = 0
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ---------------------------------------------------------------------------
|
| 55 |
+
# Text tokenisation (BM25 corpus)
|
| 56 |
+
# ---------------------------------------------------------------------------
|
| 57 |
+
|
| 58 |
+
_TOKEN_RE = re.compile(r"[a-z0-9+#\.]+")
|
| 59 |
+
# Pre-compile lowercasing noop; use str.translate for speed
|
| 60 |
+
_TR_TABLE = str.maketrans("", "", ".,;:!?'\"()[]{}<>")
|
| 61 |
+
_SKILL_HINT_RE = re.compile(
|
| 62 |
+
r"\b("
|
| 63 |
+
r"pytorch|tensorflow|transformers?|hugging|llm|rag|retrieval|recommend"
|
| 64 |
+
r"|ranking|ranker|search|embedding|vector|faiss|pinecone|weaviate|qdrant"
|
| 65 |
+
r"|milvus|opensearch|elasticsearch|bm25|xgboost|lightgbm|catboost"
|
| 66 |
+
r"|learning[\s-]?to[\s-]?rank|lambdarank|lora|qlora|peft|fine[\s-]?tun"
|
| 67 |
+
r"|sentence[\s-]?transformer|bge|e5|mlops|kubeflow|mlflow|triton|onnx"
|
| 68 |
+
r"|nlp|computer vision|cv|numpy|pandas|spark|airflow|kafka|databricks"
|
| 69 |
+
r"|snowflake|distributed|production|deploy|inference|eval|ndcg|mrr|map"
|
| 70 |
+
r"|a/b|ab test|hybrid|recsys|recommender|search relevance|rank model"
|
| 71 |
+
r"|promotion velocity"
|
| 72 |
+
r")\b",
|
| 73 |
+
re.IGNORECASE,
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
MAX_TEXT_CHARS = 4000 # BM25 memory control
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _tokenize(text: str) -> List[str]:
|
| 80 |
+
"""Unigram tokenization only. Bigrams removed because they reward
|
| 81 |
+
keyword-stuffing honeypot profiles that pack AI buzzwords densely."""
|
| 82 |
+
if not text:
|
| 83 |
+
return []
|
| 84 |
+
text = text.lower().translate(_TR_TABLE)
|
| 85 |
+
return _TOKEN_RE.findall(text)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _build_text_corpus(c: Dict) -> str:
|
| 89 |
+
parts: List[str] = []
|
| 90 |
+
if c.get("headline"):
|
| 91 |
+
parts.append(c["headline"])
|
| 92 |
+
if c.get("summary"):
|
| 93 |
+
parts.append(c["summary"])
|
| 94 |
+
for ch in c.get("career_history", []):
|
| 95 |
+
if ch.get("title"):
|
| 96 |
+
parts.append(ch["title"])
|
| 97 |
+
if ch.get("company"):
|
| 98 |
+
parts.append(ch["company"])
|
| 99 |
+
if ch.get("description"):
|
| 100 |
+
parts.append(ch["description"])
|
| 101 |
+
for s in c.get("skills", []):
|
| 102 |
+
parts.append(s.get("name", ""))
|
| 103 |
+
for cert in c.get("certifications", []):
|
| 104 |
+
parts.append(cert.get("name", ""))
|
| 105 |
+
for lang in c.get("languages", []):
|
| 106 |
+
parts.append(lang.get("language", ""))
|
| 107 |
+
for edu in c.get("education", []):
|
| 108 |
+
parts.append(edu.get("degree", ""))
|
| 109 |
+
parts.append(edu.get("field_of_study", ""))
|
| 110 |
+
text = " \n ".join([p for p in parts if p])
|
| 111 |
+
return text[:MAX_TEXT_CHARS]
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# ---------------------------------------------------------------------------
|
| 115 |
+
# Career timeline integrity
|
| 116 |
+
# ---------------------------------------------------------------------------
|
| 117 |
+
|
| 118 |
+
def _months_overlap(a_start: str, a_end: Optional[str], b_start: str, b_end: Optional[str]) -> int:
|
| 119 |
+
"""Approx months overlap of two (start,end) date pairs in YYYY-MM-DD."""
|
| 120 |
+
if not a_start or not b_start:
|
| 121 |
+
return 0
|
| 122 |
+
try:
|
| 123 |
+
a1 = _parse_date(a_start)
|
| 124 |
+
a2 = _parse_date(a_end) if a_end else _TODAY
|
| 125 |
+
b1 = _parse_date(b_start)
|
| 126 |
+
b2 = _parse_date(b_end) if b_end else _TODAY
|
| 127 |
+
except Exception:
|
| 128 |
+
return 0
|
| 129 |
+
if a1 > a2 or b1 > b2:
|
| 130 |
+
return 0
|
| 131 |
+
start = a1 if a1 > b1 else b1
|
| 132 |
+
end = a2 if a2 < b2 else b2
|
| 133 |
+
if end < start:
|
| 134 |
+
return 0
|
| 135 |
+
return max(0, int((end - start) // 30))
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# Pre-parse today's date once.
|
| 139 |
+
_TODAY_DT = __import__("datetime").datetime.now()
|
| 140 |
+
_TODAY = _TODAY_DT.year * 12 + _TODAY_DT.month
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _parse_date(s: str) -> int:
|
| 144 |
+
"""Return YYYY*12+MM for a YYYY-MM-DD string. Very fast path."""
|
| 145 |
+
if not s or len(s) < 7:
|
| 146 |
+
return 0
|
| 147 |
+
try:
|
| 148 |
+
y = int(s[0:4])
|
| 149 |
+
m = int(s[5:7])
|
| 150 |
+
if not (1970 <= y <= 2099 and 1 <= m <= 12):
|
| 151 |
+
return 0
|
| 152 |
+
return y * 12 + m
|
| 153 |
+
except Exception:
|
| 154 |
+
return 0
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _career_timeline(career: List[Dict]) -> Tuple[int, int, int, int]:
|
| 158 |
+
"""Return (sum_months, overlap_months, has_overlap, is_impossible)."""
|
| 159 |
+
total = 0
|
| 160 |
+
overlap = 0
|
| 161 |
+
n_impossible = 0
|
| 162 |
+
n = len(career)
|
| 163 |
+
spans = []
|
| 164 |
+
for ch in career:
|
| 165 |
+
dur = int(ch.get("duration_months") or 0)
|
| 166 |
+
if dur < 0 or dur > 600:
|
| 167 |
+
n_impossible += 1
|
| 168 |
+
total += dur
|
| 169 |
+
a1 = _parse_date(ch.get("start_date", ""))
|
| 170 |
+
a2 = _parse_date(ch.get("end_date")) if ch.get("end_date") else _TODAY
|
| 171 |
+
is_cur = bool(ch.get("is_current"))
|
| 172 |
+
spans.append((a1, a2, is_cur))
|
| 173 |
+
# pairwise overlap of roles where at least one is not "current"
|
| 174 |
+
for i in range(n):
|
| 175 |
+
a1, a2, ac = spans[i]
|
| 176 |
+
if a1 == 0 or a2 == 0:
|
| 177 |
+
continue
|
| 178 |
+
for j in range(i + 1, n):
|
| 179 |
+
b1, b2, bc = spans[j]
|
| 180 |
+
if b1 == 0 or b2 == 0:
|
| 181 |
+
continue
|
| 182 |
+
if ac and bc:
|
| 183 |
+
continue
|
| 184 |
+
start = a1 if a1 > b1 else b1
|
| 185 |
+
end = a2 if a2 < b2 else b2
|
| 186 |
+
if end > start and (end - start) >= 6:
|
| 187 |
+
overlap += (end - start)
|
| 188 |
+
return total, overlap, int(overlap >= 6), n_impossible
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# ---------------------------------------------------------------------------
|
| 192 |
+
# Canonicalisation
|
| 193 |
+
# ---------------------------------------------------------------------------
|
| 194 |
+
|
| 195 |
+
def canonicalise(c: Dict) -> CanonicalCandidate:
|
| 196 |
+
p = c.get("profile", {}) or {}
|
| 197 |
+
career = c.get("career_history", []) or []
|
| 198 |
+
skills = c.get("skills", []) or []
|
| 199 |
+
sig = c.get("redrob_signals", {}) or {}
|
| 200 |
+
|
| 201 |
+
cc = CanonicalCandidate(
|
| 202 |
+
candidate_id=c.get("candidate_id", ""),
|
| 203 |
+
name=p.get("anonymized_name", ""),
|
| 204 |
+
headline=p.get("headline", ""),
|
| 205 |
+
summary=p.get("summary", ""),
|
| 206 |
+
location=p.get("location", ""),
|
| 207 |
+
country=p.get("country", ""),
|
| 208 |
+
yoe=float(p.get("years_of_experience") or 0.0),
|
| 209 |
+
current_title=p.get("current_title", ""),
|
| 210 |
+
current_company=p.get("current_company", ""),
|
| 211 |
+
current_company_size=p.get("current_company_size", ""),
|
| 212 |
+
current_industry=p.get("current_industry", ""),
|
| 213 |
+
career=career,
|
| 214 |
+
edu=c.get("education", []) or [],
|
| 215 |
+
skills=skills,
|
| 216 |
+
certs=c.get("certifications", []) or [],
|
| 217 |
+
languages=c.get("languages", []) or [],
|
| 218 |
+
signals=sig,
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
# Text corpus
|
| 222 |
+
cc.text_corpus = _build_text_corpus(c)
|
| 223 |
+
|
| 224 |
+
# Career timeline metrics
|
| 225 |
+
total, overlap, _has_overlap, _imp = _career_timeline(career)
|
| 226 |
+
cc.career_total_months = total
|
| 227 |
+
cc.career_overlap_months = overlap
|
| 228 |
+
cc.career_industries = list({(ch.get("industry") or "").strip() for ch in career if ch.get("industry")})
|
| 229 |
+
cc.career_titles = [(ch.get("title") or "").strip() for ch in career if ch.get("title")]
|
| 230 |
+
cc.career_companies = [(ch.get("company") or "").strip() for ch in career if ch.get("company")]
|
| 231 |
+
|
| 232 |
+
# Skills
|
| 233 |
+
cc.skill_names = [s.get("name", "").strip() for s in skills if s.get("name")]
|
| 234 |
+
for s in skills:
|
| 235 |
+
n = (s.get("name") or "").strip()
|
| 236 |
+
d = int(s.get("duration_months") or 0)
|
| 237 |
+
if n:
|
| 238 |
+
cc.skill_durations_max[n] = max(cc.skill_durations_max.get(n, 0), d)
|
| 239 |
+
# has_ai_skill flag
|
| 240 |
+
skills_lower = {n.lower() for n in cc.skill_names}
|
| 241 |
+
if skills_lower & {t.lower() for t in config.STRUCTURED_GATE_TERMS}:
|
| 242 |
+
cc.has_ai_skill = 1
|
| 243 |
+
if _SKILL_HINT_RE.search(cc.text_corpus):
|
| 244 |
+
cc.has_ai_skill = 1
|
| 245 |
+
|
| 246 |
+
return cc
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
# ---------------------------------------------------------------------------
|
| 250 |
+
# Streaming loader
|
| 251 |
+
# ---------------------------------------------------------------------------
|
| 252 |
+
|
| 253 |
+
def iter_canonical(path: Optional[Path] = None) -> Iterator[CanonicalCandidate]:
|
| 254 |
+
"""Yield canonical records from a JSONL file.
|
| 255 |
+
|
| 256 |
+
Reads line-by-line. The 100k file is ~487MB; line-based parsing with
|
| 257 |
+
json.loads is ~4x faster than ijson on this file because ijson chokes
|
| 258 |
+
on a small fraction of records with non-standard whitespace, and the
|
| 259 |
+
total memory is fine since we only hold one record at a time.
|
| 260 |
+
"""
|
| 261 |
+
p = Path(path) if path else config.DEFAULT_CANDIDATES_JSONL
|
| 262 |
+
if not p.exists():
|
| 263 |
+
raise FileNotFoundError(f"candidates file not found: {p}")
|
| 264 |
+
|
| 265 |
+
with open(p, "r", encoding="utf-8", errors="replace") as f:
|
| 266 |
+
for line in f:
|
| 267 |
+
line = line.strip()
|
| 268 |
+
if not line:
|
| 269 |
+
continue
|
| 270 |
+
try:
|
| 271 |
+
yield canonicalise(json.loads(line))
|
| 272 |
+
except json.JSONDecodeError:
|
| 273 |
+
# skip malformed records
|
| 274 |
+
continue
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def load_or_build_parquet(path: Optional[Path] = None) -> pd.DataFrame:
|
| 278 |
+
"""Return a pandas DataFrame of canonical candidates, building the
|
| 279 |
+
parquet cache on first call."""
|
| 280 |
+
p = Path(path) if path else config.DEFAULT_CANDIDATES_JSONL
|
| 281 |
+
if config.CANDIDATES_PARQUET.exists():
|
| 282 |
+
return pd.read_parquet(config.CANDIDATES_PARQUET)
|
| 283 |
+
|
| 284 |
+
rows: List[Dict] = []
|
| 285 |
+
for cc in iter_canonical(p):
|
| 286 |
+
rows.append(asdict(cc))
|
| 287 |
+
df = pd.DataFrame(rows)
|
| 288 |
+
df.to_parquet(config.CANDIDATES_PARQUET, index=False)
|
| 289 |
+
return df
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
if __name__ == "__main__":
|
| 293 |
+
df = load_or_build_parquet()
|
| 294 |
+
print(f"loaded {len(df):,} candidates -> {config.CANDIDATES_PARQUET}")
|
| 295 |
+
print("columns:", list(df.columns)[:20])
|
redrob/features/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Feature engineering: title fit, behavioral recruitability, honeypot detection."""
|
| 2 |
+
from .title_features import title_fit_features, company_tier_features
|
| 3 |
+
from .behavioral import recruitability_features
|
| 4 |
+
from .honeypot import honeypot_features
|
| 5 |
+
from .skill_features import skill_features
|
| 6 |
+
|
| 7 |
+
__all__ = [
|
| 8 |
+
"title_fit_features",
|
| 9 |
+
"company_tier_features",
|
| 10 |
+
"recruitability_features",
|
| 11 |
+
"honeypot_features",
|
| 12 |
+
"skill_features",
|
| 13 |
+
]
|
redrob/features/behavioral.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Behavioral recruitability features from the 23 Redrob signals."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pandas as pd
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _to_dt(s):
|
| 11 |
+
if not s:
|
| 12 |
+
return None
|
| 13 |
+
try:
|
| 14 |
+
return datetime.strptime(str(s)[:10], "%Y-%m-%d")
|
| 15 |
+
except Exception:
|
| 16 |
+
return None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def recruitability_features(df: pd.DataFrame) -> pd.DataFrame:
|
| 20 |
+
"""Compute a 0..1 recruitability composite plus sub-features."""
|
| 21 |
+
n = len(df)
|
| 22 |
+
sig_col = "signals"
|
| 23 |
+
if sig_col not in df.columns:
|
| 24 |
+
return pd.DataFrame({
|
| 25 |
+
"recruit_open_to_work": np.zeros(n),
|
| 26 |
+
"recruit_response_rate": np.zeros(n),
|
| 27 |
+
"recruit_verified": np.zeros(n),
|
| 28 |
+
"recruit_completeness": np.zeros(n),
|
| 29 |
+
"recruit_recency": np.zeros(n),
|
| 30 |
+
"recruit_notice_ok": np.zeros(n),
|
| 31 |
+
"recruit_recruiter_saves": np.zeros(n),
|
| 32 |
+
"recruit_interview_completion": np.zeros(n),
|
| 33 |
+
"recruit_offer_acceptance": np.zeros(n),
|
| 34 |
+
"recruit_github": np.zeros(n),
|
| 35 |
+
"recruitability": np.zeros(n),
|
| 36 |
+
})
|
| 37 |
+
|
| 38 |
+
def get(row, key, default=0):
|
| 39 |
+
d = row.get(sig_col)
|
| 40 |
+
if d is None:
|
| 41 |
+
d = {}
|
| 42 |
+
elif hasattr(d, "tolist"):
|
| 43 |
+
d = d.tolist() if d else {}
|
| 44 |
+
if not isinstance(d, dict):
|
| 45 |
+
return default
|
| 46 |
+
v = d.get(key, default)
|
| 47 |
+
return v if v is not None else default
|
| 48 |
+
|
| 49 |
+
open_to_work = np.array([float(get(r, "open_to_work_flag", 0)) for _, r in df.iterrows()])
|
| 50 |
+
response_rate = np.array([float(get(r, "recruiter_response_rate", 0.0)) for _, r in df.iterrows()])
|
| 51 |
+
verified_email = np.array([float(get(r, "verified_email", 0)) for _, r in df.iterrows()])
|
| 52 |
+
verified_phone = np.array([float(get(r, "verified_phone", 0)) for _, r in df.iterrows()])
|
| 53 |
+
linkedin = np.array([float(get(r, "linkedin_connected", 0)) for _, r in df.iterrows()])
|
| 54 |
+
completeness = np.array([float(get(r, "profile_completeness_score", 0.0)) for _, r in df.iterrows()]) / 100.0
|
| 55 |
+
notice = np.array([float(get(r, "notice_period_days", 90)) for _, r in df.iterrows()])
|
| 56 |
+
saves = np.array([float(get(r, "saved_by_recruiters_30d", 0)) for _, r in df.iterrows()])
|
| 57 |
+
interview_completion = np.array([float(get(r, "interview_completion_rate", 0.0)) for _, r in df.iterrows()])
|
| 58 |
+
offer_acceptance = np.array([float(get(r, "offer_acceptance_rate", -1.0)) for _, r in df.iterrows()])
|
| 59 |
+
github = np.array([float(get(r, "github_activity_score", -1.0)) for _, r in df.iterrows()])
|
| 60 |
+
|
| 61 |
+
# Recency: anchored to fixed reference date for determinism.
|
| 62 |
+
# Reference = 2026-06-01 (near dataset creation date).
|
| 63 |
+
# 90-day half-life exponential decay.
|
| 64 |
+
from datetime import datetime as _dt
|
| 65 |
+
_REF_DATE = _dt(2026, 6, 1)
|
| 66 |
+
recency = np.zeros(n)
|
| 67 |
+
for i, (_, r) in enumerate(df.iterrows()):
|
| 68 |
+
d = _to_dt(get(r, "last_active_date", None))
|
| 69 |
+
if d is None:
|
| 70 |
+
recency[i] = 0.0
|
| 71 |
+
continue
|
| 72 |
+
days = (_REF_DATE - d).days
|
| 73 |
+
import math
|
| 74 |
+
recency[i] = max(0.0, math.exp(-max(0, days) / 90.0))
|
| 75 |
+
|
| 76 |
+
# Notice period
|
| 77 |
+
notice_ok = np.where(notice <= 30, 1.0, np.maximum(0.0, 1.0 - (notice - 30) / 150.0))
|
| 78 |
+
recruiter_saves = np.minimum(saves / 20.0, 1.0)
|
| 79 |
+
offer_acc = np.where(offer_acceptance < 0, 0.3, offer_acceptance)
|
| 80 |
+
offer_acc = np.clip(offer_acc, 0.0, 1.0)
|
| 81 |
+
github_norm = np.where(github < 0, 0.0, github / 100.0)
|
| 82 |
+
verified = (verified_email + verified_phone + linkedin) / 3.0
|
| 83 |
+
|
| 84 |
+
composite = (
|
| 85 |
+
0.18 * open_to_work
|
| 86 |
+
+ 0.10 * verified
|
| 87 |
+
+ 0.10 * response_rate
|
| 88 |
+
+ 0.06 * interview_completion
|
| 89 |
+
+ 0.06 * offer_acc
|
| 90 |
+
+ 0.10 * completeness
|
| 91 |
+
+ 0.08 * recency
|
| 92 |
+
+ 0.10 * notice_ok
|
| 93 |
+
+ 0.10 * recruiter_saves
|
| 94 |
+
+ 0.06 * github_norm
|
| 95 |
+
+ 0.06 * open_to_work * verified # bonus: open AND verified
|
| 96 |
+
)
|
| 97 |
+
composite = np.clip(composite, 0.0, 1.0)
|
| 98 |
+
|
| 99 |
+
return pd.DataFrame({
|
| 100 |
+
"recruit_open_to_work": open_to_work,
|
| 101 |
+
"recruit_response_rate": response_rate,
|
| 102 |
+
"recruit_verified": verified,
|
| 103 |
+
"recruit_completeness": completeness,
|
| 104 |
+
"recruit_recency": recency,
|
| 105 |
+
"recruit_notice_ok": notice_ok,
|
| 106 |
+
"recruit_recruiter_saves": recruiter_saves,
|
| 107 |
+
"recruit_interview_completion": interview_completion,
|
| 108 |
+
"recruit_offer_acceptance": offer_acc,
|
| 109 |
+
"recruit_github": github_norm,
|
| 110 |
+
"recruitability": composite,
|
| 111 |
+
})
|
redrob/features/honeypot.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Explicit honeypot detection.
|
| 2 |
+
|
| 3 |
+
We do not learn the contradictions. We encode ten hard rules and
|
| 4 |
+
combine them into a single penalty ∈ [0, 1].
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import re
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import pandas as pd
|
| 13 |
+
|
| 14 |
+
from .. import config
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
_SKILL_AI = re.compile(
|
| 18 |
+
r"\b(pytorch|tensorflow|transformers?|hugging|llm|rag|retrieval|recommend"
|
| 19 |
+
r"|ranking|ranker|search|embedding|vector|faiss|pinecone|weaviate|qdrant"
|
| 20 |
+
r"|milvus|opensearch|elasticsearch|bm25|xgboost|lightgbm|catboost"
|
| 21 |
+
r"|learning[\s-]?to[\s-]?rank|lambdarank|lora|qlora|peft|fine[\s-]?tun"
|
| 22 |
+
r"|sentence[\s-]?transformer|bge|e5|mlops|kubeflow|mlflow|triton|onnx)\b",
|
| 23 |
+
re.IGNORECASE,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _is_ai_skill(name: str) -> bool:
|
| 28 |
+
return bool(_SKILL_AI.search(name or ""))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def honeypot_features(df: pd.DataFrame) -> pd.DataFrame:
|
| 32 |
+
n = len(df)
|
| 33 |
+
p = np.zeros(n)
|
| 34 |
+
rule_flags = {f"hp_rule_{i}": np.zeros(n) for i in range(10)}
|
| 35 |
+
|
| 36 |
+
# Pre-extract per-row arrays
|
| 37 |
+
skill_names = []
|
| 38 |
+
for x in df["skill_names"].tolist():
|
| 39 |
+
if x is None:
|
| 40 |
+
skill_names.append([])
|
| 41 |
+
elif hasattr(x, "tolist"):
|
| 42 |
+
skill_names.append(x.tolist())
|
| 43 |
+
else:
|
| 44 |
+
skill_names.append(x)
|
| 45 |
+
skills_full = []
|
| 46 |
+
for x in df["skills"].tolist():
|
| 47 |
+
if x is None:
|
| 48 |
+
skills_full.append([])
|
| 49 |
+
elif hasattr(x, "tolist"):
|
| 50 |
+
skills_full.append(x.tolist())
|
| 51 |
+
else:
|
| 52 |
+
skills_full.append(x)
|
| 53 |
+
yoe = df["yoe"].astype(float).to_numpy()
|
| 54 |
+
career_total = df["career_total_months"].astype(float).to_numpy() / 12.0
|
| 55 |
+
overlap = df["career_overlap_months"].astype(float).to_numpy()
|
| 56 |
+
edu = []
|
| 57 |
+
for x in df["edu"].tolist():
|
| 58 |
+
if x is None:
|
| 59 |
+
edu.append([])
|
| 60 |
+
elif hasattr(x, "tolist"):
|
| 61 |
+
edu.append(x.tolist())
|
| 62 |
+
else:
|
| 63 |
+
edu.append(x)
|
| 64 |
+
sigs = []
|
| 65 |
+
for s in df["signals"].tolist():
|
| 66 |
+
if s is None:
|
| 67 |
+
sigs.append({})
|
| 68 |
+
elif hasattr(s, "tolist"):
|
| 69 |
+
sigs.append(s.tolist() if s else {})
|
| 70 |
+
else:
|
| 71 |
+
sigs.append(s)
|
| 72 |
+
profile_completeness = np.array([
|
| 73 |
+
(s or {}).get("profile_completeness_score", 50.0) if isinstance(s, dict) else 50.0
|
| 74 |
+
for s in sigs
|
| 75 |
+
])
|
| 76 |
+
salary_min = np.array([
|
| 77 |
+
((s or {}).get("expected_salary_range_inr_lpa") or {}).get("min", 0.0) if isinstance(s, dict) else 0.0
|
| 78 |
+
for s in sigs
|
| 79 |
+
])
|
| 80 |
+
salary_max = np.array([
|
| 81 |
+
((s or {}).get("expected_salary_range_inr_lpa") or {}).get("max", 0.0) if isinstance(s, dict) else 0.0
|
| 82 |
+
for s in sigs
|
| 83 |
+
])
|
| 84 |
+
search_appearance = np.array([
|
| 85 |
+
(s or {}).get("search_appearance_30d", 0) if isinstance(s, dict) else 0
|
| 86 |
+
for s in sigs
|
| 87 |
+
])
|
| 88 |
+
saved_by_recruiters = np.array([
|
| 89 |
+
(s or {}).get("saved_by_recruiters_30d", 0) if isinstance(s, dict) else 0
|
| 90 |
+
for s in sigs
|
| 91 |
+
])
|
| 92 |
+
|
| 93 |
+
for i in range(n):
|
| 94 |
+
contrib = 0.0
|
| 95 |
+
_skf = skills_full[i]
|
| 96 |
+
if _skf is None:
|
| 97 |
+
_skf = []
|
| 98 |
+
elif hasattr(_skf, "tolist"):
|
| 99 |
+
_skf = _skf.tolist()
|
| 100 |
+
_sn = skill_names[i]
|
| 101 |
+
if _sn is None:
|
| 102 |
+
_sn = []
|
| 103 |
+
elif hasattr(_sn, "tolist"):
|
| 104 |
+
_sn = _sn.tolist()
|
| 105 |
+
|
| 106 |
+
# Rule 1: expert + duration_months < 6 (capped)
|
| 107 |
+
cnt = 0
|
| 108 |
+
for s in _skf:
|
| 109 |
+
if s.get("proficiency") == "expert" and (s.get("duration_months") or 0) < 6 and _is_ai_skill(s.get("name", "")):
|
| 110 |
+
cnt += 1
|
| 111 |
+
rule_flags["hp_rule_0"][i] = min(cnt * 0.25, 0.5)
|
| 112 |
+
contrib += rule_flags["hp_rule_0"][i]
|
| 113 |
+
|
| 114 |
+
# Rule 2: advanced/expert + 0 months
|
| 115 |
+
cnt2 = 0
|
| 116 |
+
for s in _skf:
|
| 117 |
+
if s.get("proficiency") in ("advanced", "expert") and (s.get("duration_months") or 0) == 0 and _is_ai_skill(s.get("name", "")):
|
| 118 |
+
cnt2 += 1
|
| 119 |
+
rule_flags["hp_rule_1"][i] = min(cnt2 * 0.15, 0.3)
|
| 120 |
+
contrib += rule_flags["hp_rule_1"][i]
|
| 121 |
+
|
| 122 |
+
# Rule 3: career duration > YOE + 1.5 years
|
| 123 |
+
if yoe[i] > 0 and career_total[i] > yoe[i] + 1.5:
|
| 124 |
+
rule_flags["hp_rule_2"][i] = 0.25
|
| 125 |
+
contrib += 0.25
|
| 126 |
+
|
| 127 |
+
# Rule 4: concurrent overlap > 12 months
|
| 128 |
+
if overlap[i] >= 12:
|
| 129 |
+
rule_flags["hp_rule_3"][i] = 0.20
|
| 130 |
+
contrib += 0.20
|
| 131 |
+
|
| 132 |
+
# Rule 5: education anomalies
|
| 133 |
+
bad_edu = 0
|
| 134 |
+
_edu = edu[i]
|
| 135 |
+
if _edu is None:
|
| 136 |
+
_edu = []
|
| 137 |
+
elif hasattr(_edu, "tolist"):
|
| 138 |
+
_edu = _edu.tolist()
|
| 139 |
+
for e in _edu:
|
| 140 |
+
sy = int(e.get("start_year") or 0)
|
| 141 |
+
ey = int(e.get("end_year") or 0)
|
| 142 |
+
if ey and sy and ey < sy:
|
| 143 |
+
bad_edu += 1
|
| 144 |
+
elif sy and ey:
|
| 145 |
+
deg = (e.get("degree") or "").lower()
|
| 146 |
+
expected = 2 if "phd" in deg or "doctor" in deg else (1 if "master" in deg or "m.sc" in deg or "m.s." in deg or "m.e" in deg or "m.tech" in deg or "mba" in deg else 4)
|
| 147 |
+
if (ey - sy) < expected - 1 and ey >= 2020:
|
| 148 |
+
bad_edu += 1
|
| 149 |
+
if bad_edu:
|
| 150 |
+
rule_flags["hp_rule_4"][i] = min(bad_edu * 0.10, 0.20)
|
| 151 |
+
contrib += rule_flags["hp_rule_4"][i]
|
| 152 |
+
|
| 153 |
+
# Rule 6: many advanced skills with low YOE
|
| 154 |
+
adv = sum(1 for s in _skf if s.get("proficiency") in ("advanced", "expert"))
|
| 155 |
+
if adv >= 6 and yoe[i] < 3:
|
| 156 |
+
rule_flags["hp_rule_5"][i] = 0.25
|
| 157 |
+
contrib += 0.25
|
| 158 |
+
|
| 159 |
+
# Rule 7 v2: title-skill contradiction (loosened).
|
| 160 |
+
# Fires when current_title is non-eng AND
|
| 161 |
+
# (a) every historical title is non-eng OR
|
| 162 |
+
# (b) max assessment >= 80 (unrealistic for non-eng) OR
|
| 163 |
+
# (c) career_total_months < yoe * 12 * 0.85 (implausibly short career).
|
| 164 |
+
if adv >= 5:
|
| 165 |
+
non_eng_kw = (
|
| 166 |
+
"marketing", "hr ", "accountant", "sales ", "graphic", "content writer",
|
| 167 |
+
"civil", "mechanical", "customer support", "operations manager",
|
| 168 |
+
"project manager", "business analyst", "qa ", "frontend", "java developer",
|
| 169 |
+
".net", "mobile developer", "devops", "cloud engineer", "data analyst",
|
| 170 |
+
)
|
| 171 |
+
cur = (df.iloc[i]["current_title"] or "").lower()
|
| 172 |
+
hist_titles = df.iloc[i]["career_titles"]
|
| 173 |
+
if hist_titles is None:
|
| 174 |
+
hist_titles = []
|
| 175 |
+
elif hasattr(hist_titles, "tolist"):
|
| 176 |
+
hist_titles = hist_titles.tolist()
|
| 177 |
+
non_eng_cur = any(k in cur for k in non_eng_kw)
|
| 178 |
+
all_non_eng_hist = all(any(k in (t or "").lower() for k in non_eng_kw)
|
| 179 |
+
for t in hist_titles if t)
|
| 180 |
+
# Compute max assessment score
|
| 181 |
+
max_assess = 0
|
| 182 |
+
sig = sigs[i]
|
| 183 |
+
if isinstance(sig, dict):
|
| 184 |
+
sas = sig.get("skill_assessment_scores") or {}
|
| 185 |
+
for v in (sas or {}).values():
|
| 186 |
+
if isinstance(v, (int, float)) and v > max_assess:
|
| 187 |
+
max_assess = v
|
| 188 |
+
# Career-impossible
|
| 189 |
+
impossible_career = (
|
| 190 |
+
yoe[i] > 0 and career_total[i] < yoe[i] * 0.85
|
| 191 |
+
)
|
| 192 |
+
if non_eng_cur and (all_non_eng_hist or max_assess >= 80 or impossible_career):
|
| 193 |
+
rule_flags["hp_rule_6"][i] = 0.30
|
| 194 |
+
contrib += 0.30
|
| 195 |
+
|
| 196 |
+
# Rule 8: profile stub with rich skills
|
| 197 |
+
if profile_completeness[i] < 35 and adv >= 8:
|
| 198 |
+
rule_flags["hp_rule_7"][i] = 0.15
|
| 199 |
+
contrib += 0.15
|
| 200 |
+
|
| 201 |
+
# Rule 9: salary inversion
|
| 202 |
+
if salary_min[i] > salary_max[i] and salary_min[i] > 0:
|
| 203 |
+
rule_flags["hp_rule_8"][i] = 0.10
|
| 204 |
+
contrib += 0.10
|
| 205 |
+
|
| 206 |
+
# Rule 10: signals fakery
|
| 207 |
+
if search_appearance[i] > 500 and saved_by_recruiters[i] == 0:
|
| 208 |
+
rule_flags["hp_rule_9"][i] = 0.10
|
| 209 |
+
contrib += 0.10
|
| 210 |
+
|
| 211 |
+
p[i] = min(contrib, 1.0)
|
| 212 |
+
|
| 213 |
+
out = pd.DataFrame({"honeypot_penalty": p})
|
| 214 |
+
|
| 215 |
+
# Rule 11: behavioral twins (post-pass) — detect candidates whose
|
| 216 |
+
# (name, current_title, round(yoe,1)) match another candidate and
|
| 217 |
+
# penalise the weaker-behavior twin. Matches the spec's explicit
|
| 218 |
+
# "behavioral twins" trap (530 twin-keys, 1,061 candidates).
|
| 219 |
+
from collections import defaultdict
|
| 220 |
+
sigs_for_twins = sigs
|
| 221 |
+
rr_arr = np.array([
|
| 222 |
+
(s.get("recruiter_response_rate", 0.0) if isinstance(s, dict) else 0.0)
|
| 223 |
+
for s in sigs_for_twins
|
| 224 |
+
], dtype=np.float32)
|
| 225 |
+
recency_arr = np.array([
|
| 226 |
+
(s.get("recency_score", s.get("last_active_date_score", 0.5)) if isinstance(s, dict) else 0.5)
|
| 227 |
+
for s in sigs_for_twins
|
| 228 |
+
], dtype=np.float32)
|
| 229 |
+
names = df["name"].fillna("").astype(str).tolist() if "name" in df.columns \
|
| 230 |
+
else df["candidate_id"].astype(str).tolist()
|
| 231 |
+
titles = df["current_title"].fillna("").astype(str).tolist()
|
| 232 |
+
yoe_round = np.round(yoe, 1)
|
| 233 |
+
twin_groups: dict = defaultdict(list)
|
| 234 |
+
for i in range(n):
|
| 235 |
+
key = (names[i].strip().lower(), titles[i].strip().lower(), float(yoe_round[i]))
|
| 236 |
+
if key[0] == "":
|
| 237 |
+
continue
|
| 238 |
+
twin_groups[key].append(i)
|
| 239 |
+
twin_penalty = np.zeros(n, dtype=np.float32)
|
| 240 |
+
for key, idxs in twin_groups.items():
|
| 241 |
+
if len(idxs) < 2:
|
| 242 |
+
continue
|
| 243 |
+
# Pick the strongest twin (highest rr + recency); penalise the rest
|
| 244 |
+
strengths = rr_arr[idxs] + recency_arr[idxs]
|
| 245 |
+
order = np.argsort(-strengths)
|
| 246 |
+
leader = idxs[order[0]]
|
| 247 |
+
for weaker in [idxs[i] for i in order[1:]]:
|
| 248 |
+
twin_penalty[weaker] += 0.15
|
| 249 |
+
out["honeypot_penalty"] = np.minimum(out["honeypot_penalty"].to_numpy() + twin_penalty, 1.0)
|
| 250 |
+
out["hp_rule_10"] = twin_penalty
|
| 251 |
+
for k, v in rule_flags.items():
|
| 252 |
+
out[k] = v
|
| 253 |
+
return out
|
redrob/features/skill_features.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Skill-related features: must-have coverage, assessments, durations."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import numpy as np
|
| 5 |
+
import pandas as pd
|
| 6 |
+
|
| 7 |
+
from .. import config
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
_CORE = {s.lower() for s in config.CORE_COMPETENCIES}
|
| 11 |
+
_ADJ = {s.lower() for s in config.ADJACENT_COMPETENCIES}
|
| 12 |
+
_NEG = {s.lower() for s in config.NEGATIVE_COMPETENCIES}
|
| 13 |
+
_CRIT = {s.lower() for s in config.JD_CRITICAL}
|
| 14 |
+
_NICE = {s.lower() for s in config.JD_NICE_TO_HAVE}
|
| 15 |
+
_SKILL_WEIGHT = {s: 2.0 for s in _CRIT}
|
| 16 |
+
_SKILL_WEIGHT.update({s: 1.0 for s in _NICE})
|
| 17 |
+
for s in _CORE - _CRIT - _NICE:
|
| 18 |
+
_SKILL_WEIGHT.setdefault(s, 0.5)
|
| 19 |
+
# Max possible weighted score (6 critical + 4 nice-to-have)
|
| 20 |
+
_MAX_WEIGHT = sum(sorted(_SKILL_WEIGHT.values(), reverse=True)[:10])
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# ----------------------------------------------------------------------
|
| 24 |
+
# Skill alias / canonicalization map (added by agent review, Exp #8)
|
| 25 |
+
# ----------------------------------------------------------------------
|
| 26 |
+
# Maps variant skill names (as they appear in candidates.jsonl and
|
| 27 |
+
# Redrob assessments) to their canonical JD-core counterparts.
|
| 28 |
+
# This boosts recall for candidates who list the same skill under a
|
| 29 |
+
# slightly different name (e.g. "Fine-tuning LLMs" → "Fine-tuning",
|
| 30 |
+
# "pgvector" → vector DB family, "Search Relevance" → BM25 family).
|
| 31 |
+
_SKILL_ALIAS = {
|
| 32 |
+
# Retrieval / vector DB family
|
| 33 |
+
"pgvector": "vector database",
|
| 34 |
+
"pgvector (postgres vector)": "vector database",
|
| 35 |
+
"vector database": "vector database",
|
| 36 |
+
"vector databases": "vector database",
|
| 37 |
+
"vector db": "vector database",
|
| 38 |
+
"dense retrieval": "dense retrieval",
|
| 39 |
+
"dense passage retrieval": "dense retrieval",
|
| 40 |
+
"dense passage retrieval (dpr)": "dense retrieval",
|
| 41 |
+
"approximate nearest neighbor": "vector database",
|
| 42 |
+
"faiss": "faiss",
|
| 43 |
+
"ann": "faiss",
|
| 44 |
+
"hnsw": "faiss",
|
| 45 |
+
"scaNN": "faiss",
|
| 46 |
+
"qdrant": "qdrant",
|
| 47 |
+
# Search ranking family
|
| 48 |
+
"search relevance": "bm25",
|
| 49 |
+
"search ranking": "bm25",
|
| 50 |
+
"relevance ranking": "learning to rank",
|
| 51 |
+
"relevance": "bm25",
|
| 52 |
+
"search engineer": "bm25",
|
| 53 |
+
"lexical search": "bm25",
|
| 54 |
+
"tf-idf": "bm25",
|
| 55 |
+
"tfidf": "bm25",
|
| 56 |
+
# LLM / fine-tuning family
|
| 57 |
+
"fine-tuning llms": "fine-tuning",
|
| 58 |
+
"llm fine-tuning": "fine-tuning",
|
| 59 |
+
"llm finetuning": "fine-tuning",
|
| 60 |
+
"fine-tuning": "fine-tuning",
|
| 61 |
+
"finetuning": "fine-tuning",
|
| 62 |
+
"instruction tuning": "fine-tuning",
|
| 63 |
+
"rlhf": "fine-tuning",
|
| 64 |
+
"rlhf (reinforcement learning from human feedback)": "fine-tuning",
|
| 65 |
+
"prompt tuning": "prompt engineering",
|
| 66 |
+
"prompt engineering": "prompt engineering",
|
| 67 |
+
"prompt design": "prompt engineering",
|
| 68 |
+
"prompt crafting": "prompt engineering",
|
| 69 |
+
"rag (retrieval-augmented generation)": "rag",
|
| 70 |
+
"retrieval augmented generation (rag)": "rag",
|
| 71 |
+
"retrieval-augmented generation (rag)": "rag",
|
| 72 |
+
"retrieval augmented generation": "rag",
|
| 73 |
+
"rag": "rag",
|
| 74 |
+
# Evaluation family
|
| 75 |
+
"learning-to-rank": "learning to rank",
|
| 76 |
+
"learning to rank (ltr)": "learning to rank",
|
| 77 |
+
"ltr (learning to rank)": "learning to rank",
|
| 78 |
+
"learning2rank": "learning to rank",
|
| 79 |
+
"lambdarank": "lambdarank",
|
| 80 |
+
"lambda rank": "lambdarank",
|
| 81 |
+
"lambdamart": "lambdarank",
|
| 82 |
+
"ndcg (normalised discounted cumulative gain)": "ndcg",
|
| 83 |
+
"mrr (mean reciprocal rank)": "mrr",
|
| 84 |
+
"map (mean average precision)": "map",
|
| 85 |
+
"mean reciprocal rank": "mrr",
|
| 86 |
+
"mean average precision": "map",
|
| 87 |
+
"normalised discounted cumulative gain": "ndcg",
|
| 88 |
+
# Embeddings / encoders
|
| 89 |
+
"sentence-transformer": "sentence transformers",
|
| 90 |
+
"sentence transformer": "sentence transformers",
|
| 91 |
+
"sentence-transformers": "sentence transformers",
|
| 92 |
+
"sbert": "sentence transformers",
|
| 93 |
+
"use (universal sentence encoder)": "sentence transformers",
|
| 94 |
+
"bge (bge embedding)": "bge",
|
| 95 |
+
"e5 (text embeddings)": "e5",
|
| 96 |
+
# Hybrid search
|
| 97 |
+
"hybrid retrieval": "hybrid search",
|
| 98 |
+
"hybrid ranking": "hybrid search",
|
| 99 |
+
"hybrid information retrieval": "hybrid search",
|
| 100 |
+
# OpenSearch / Elasticsearch
|
| 101 |
+
"elastic search": "elasticsearch",
|
| 102 |
+
"opensearch (managed elasticsearch)": "opensearch",
|
| 103 |
+
# Pinecone / Weaviate
|
| 104 |
+
"pinecone (vector database)": "pinecone",
|
| 105 |
+
"weaviate (vector database)": "weaviate",
|
| 106 |
+
# LLM
|
| 107 |
+
"large language models": "llm",
|
| 108 |
+
"large language model (llm)": "llm",
|
| 109 |
+
"llm (large language models)": "llm",
|
| 110 |
+
"llms": "llm",
|
| 111 |
+
# NLP
|
| 112 |
+
"natural language processing": "nlp",
|
| 113 |
+
"nlp (natural language processing)": "nlp",
|
| 114 |
+
# Vector Search alternative
|
| 115 |
+
"vector search (semantic search)": "embeddings",
|
| 116 |
+
"vector search": "embeddings",
|
| 117 |
+
"semantic search": "embeddings",
|
| 118 |
+
"approximate nearest neighbours": "vector database",
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _canon_skill(name: str) -> str:
|
| 123 |
+
"""Return the canonical form of a skill name. Falls back to lower(name)."""
|
| 124 |
+
n = (name or "").strip().lower()
|
| 125 |
+
if not n:
|
| 126 |
+
return ""
|
| 127 |
+
return _SKILL_ALIAS.get(n, n)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _canon_skill_set(names: set) -> set:
|
| 131 |
+
"""Apply canonicalization to a set of skill names."""
|
| 132 |
+
return {_canon_skill(n) for n in names if n}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def skill_features(df: pd.DataFrame) -> pd.DataFrame:
|
| 136 |
+
n = len(df)
|
| 137 |
+
|
| 138 |
+
def _as_list(v):
|
| 139 |
+
if v is None:
|
| 140 |
+
return []
|
| 141 |
+
if hasattr(v, "tolist"):
|
| 142 |
+
return v.tolist()
|
| 143 |
+
return v
|
| 144 |
+
|
| 145 |
+
skills_full_col = [_as_list(x) for x in df["skills"].tolist()]
|
| 146 |
+
sigs_col = [_as_list(x) for x in df["signals"].tolist()]
|
| 147 |
+
n_core_hits = np.zeros(n, dtype=np.int32)
|
| 148 |
+
n_adj_hits = np.zeros(n, dtype=np.int32)
|
| 149 |
+
n_neg_hits = np.zeros(n, dtype=np.int32)
|
| 150 |
+
n_advanced = np.zeros(n, dtype=np.int32)
|
| 151 |
+
n_expert = np.zeros(n, dtype=np.int32)
|
| 152 |
+
assessment_max = np.zeros(n, dtype=np.float32)
|
| 153 |
+
assessment_mean = np.zeros(n, dtype=np.float32)
|
| 154 |
+
endorsement_log_mean = np.zeros(n, dtype=np.float32)
|
| 155 |
+
duration_log_mean = np.zeros(n, dtype=np.float32)
|
| 156 |
+
skill_count = np.zeros(n, dtype=np.int32)
|
| 157 |
+
jaccard_core = np.zeros(n, dtype=np.float32)
|
| 158 |
+
jd_crit_score = np.zeros(n, dtype=np.float32)
|
| 159 |
+
# Alias-canonicalized sets (for matching against core/jd-critical)
|
| 160 |
+
names_canon_lc_col = []
|
| 161 |
+
|
| 162 |
+
for i in range(n):
|
| 163 |
+
skills_full = skills_full_col[i]
|
| 164 |
+
if skills_full is None:
|
| 165 |
+
skills_full = []
|
| 166 |
+
names = [s.get("name", "").strip() for s in skills_full if s.get("name")]
|
| 167 |
+
names_lc = {n_.lower() for n_ in names if n_}
|
| 168 |
+
names_canon_lc = _canon_skill_set(names_lc)
|
| 169 |
+
names_canon_lc_col.append(names_canon_lc)
|
| 170 |
+
skill_count[i] = len(names)
|
| 171 |
+
n_core_hits[i] = len(names_canon_lc & _CORE)
|
| 172 |
+
n_adj_hits[i] = len(names_canon_lc & _ADJ)
|
| 173 |
+
n_neg_hits[i] = len(names_canon_lc & _NEG)
|
| 174 |
+
n_advanced[i] = sum(1 for s in skills_full if s.get("proficiency") in ("advanced",))
|
| 175 |
+
n_expert[i] = sum(1 for s in skills_full if s.get("proficiency") == "expert")
|
| 176 |
+
|
| 177 |
+
# JD-criticality weighted score (uses canonical names)
|
| 178 |
+
w_sum = sum(_SKILL_WEIGHT.get(n, 0.0) for n in names_canon_lc)
|
| 179 |
+
jd_crit_score[i] = min(w_sum / max(_MAX_WEIGHT, 1e-9), 1.0)
|
| 180 |
+
|
| 181 |
+
# Assessments — canonicalize keys to merge aliases (e.g. "pgvector" and
|
| 182 |
+
# "vector database" both map to the same canonical key for aggregation)
|
| 183 |
+
sig = sigs_col[i]
|
| 184 |
+
if sig is None:
|
| 185 |
+
sig = {}
|
| 186 |
+
if isinstance(sig, dict):
|
| 187 |
+
sas = sig.get("skill_assessment_scores") or {}
|
| 188 |
+
# Canonicalize keys and merge values (take max for same canonical)
|
| 189 |
+
canon_sas: dict = {}
|
| 190 |
+
for k, v in sas.items():
|
| 191 |
+
if v is None or not isinstance(v, (int, float)):
|
| 192 |
+
continue
|
| 193 |
+
ck = _canon_skill(k)
|
| 194 |
+
if ck in canon_sas:
|
| 195 |
+
if v > canon_sas[ck]:
|
| 196 |
+
canon_sas[ck] = float(v)
|
| 197 |
+
else:
|
| 198 |
+
canon_sas[ck] = float(v)
|
| 199 |
+
if canon_sas:
|
| 200 |
+
vals = list(canon_sas.values())
|
| 201 |
+
assessment_max[i] = max(vals)
|
| 202 |
+
assessment_mean[i] = sum(vals) / len(vals)
|
| 203 |
+
# Endorsement / duration log means
|
| 204 |
+
e_vals = [int(s.get("endorsements") or 0) for s in skills_full]
|
| 205 |
+
d_vals = [int(s.get("duration_months") or 0) for s in skills_full]
|
| 206 |
+
if e_vals:
|
| 207 |
+
endorsement_log_mean[i] = float(np.mean(np.log1p(e_vals)))
|
| 208 |
+
if d_vals:
|
| 209 |
+
duration_log_mean[i] = float(np.mean(np.log1p(d_vals)))
|
| 210 |
+
|
| 211 |
+
# Jaccard of candidate's skills with core (canonicalized)
|
| 212 |
+
if names_canon_lc and _CORE:
|
| 213 |
+
inter = len(names_canon_lc & _CORE)
|
| 214 |
+
union = len(names_canon_lc | _CORE)
|
| 215 |
+
jaccard_core[i] = inter / max(union, 1)
|
| 216 |
+
|
| 217 |
+
must_have_coverage = np.minimum(n_core_hits / 8.0, 1.0) # saturates at 8 core skills
|
| 218 |
+
adj_coverage = np.minimum(n_adj_hits / 5.0, 1.0)
|
| 219 |
+
ai_signal_strength = (n_core_hits + 0.5 * n_adj_hits) / np.maximum(1, skill_count)
|
| 220 |
+
|
| 221 |
+
return pd.DataFrame({
|
| 222 |
+
"skill_count": skill_count,
|
| 223 |
+
"n_core_skills": n_core_hits,
|
| 224 |
+
"n_adj_skills": n_adj_hits,
|
| 225 |
+
"n_neg_skills": n_neg_hits,
|
| 226 |
+
"n_advanced_skills": n_advanced,
|
| 227 |
+
"n_expert_skills": n_expert,
|
| 228 |
+
"must_have_coverage": must_have_coverage,
|
| 229 |
+
"adjacent_coverage": adj_coverage,
|
| 230 |
+
"ai_signal_strength": ai_signal_strength,
|
| 231 |
+
"assessment_max": assessment_max,
|
| 232 |
+
"assessment_mean": assessment_mean,
|
| 233 |
+
"endorsement_log_mean": endorsement_log_mean,
|
| 234 |
+
"duration_log_mean": duration_log_mean,
|
| 235 |
+
"jaccard_core": jaccard_core,
|
| 236 |
+
"jd_criticality_score": jd_crit_score,
|
| 237 |
+
})
|
redrob/features/title_features.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Graduated title-fit scoring.
|
| 2 |
+
|
| 3 |
+
Combines:
|
| 4 |
+
- exact role-group match on current and historical titles
|
| 5 |
+
- embedding cosine of current title to the centroid of target titles
|
| 6 |
+
- promotion velocity / role progression features
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
from typing import List
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import pandas as pd
|
| 15 |
+
|
| 16 |
+
from .. import config
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _normalise(title: str) -> str:
|
| 20 |
+
t = (title or "").lower()
|
| 21 |
+
t = re.sub(r"[^a-z0-9+\-# ]+", " ", t)
|
| 22 |
+
t = re.sub(r"\s+", " ", t).strip()
|
| 23 |
+
return t
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _title_group(title_norm: str) -> str | None:
|
| 27 |
+
best = None
|
| 28 |
+
for group, members in config.TITLE_ROLE_GROUPS.items():
|
| 29 |
+
for m in members:
|
| 30 |
+
if m in title_norm:
|
| 31 |
+
if best is None or config.TITLE_GROUP_WEIGHT[group] > config.TITLE_GROUP_WEIGHT[best]:
|
| 32 |
+
best = group
|
| 33 |
+
return best
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def title_fit_features(df: pd.DataFrame) -> pd.DataFrame:
|
| 37 |
+
"""Return a DataFrame of per-candidate title features."""
|
| 38 |
+
current = df["current_title"].fillna("").tolist()
|
| 39 |
+
histories = df["career_titles"].tolist()
|
| 40 |
+
|
| 41 |
+
# Pre-compute best historical group across all rows
|
| 42 |
+
current_groups = [_title_group(_normalise(t)) for t in current]
|
| 43 |
+
history_groups: List[List[str]] = []
|
| 44 |
+
history_max_weight: List[float] = []
|
| 45 |
+
for hist in histories:
|
| 46 |
+
if hist is None:
|
| 47 |
+
hist = []
|
| 48 |
+
if hasattr(hist, "tolist"):
|
| 49 |
+
hist = hist.tolist()
|
| 50 |
+
gs = [_title_group(_normalise(t)) for t in (hist or [])]
|
| 51 |
+
gs = [g for g in gs if g is not None]
|
| 52 |
+
history_groups.append(gs)
|
| 53 |
+
if gs:
|
| 54 |
+
history_max_weight.append(max(config.TITLE_GROUP_WEIGHT[g] for g in gs))
|
| 55 |
+
else:
|
| 56 |
+
history_max_weight.append(0.0)
|
| 57 |
+
|
| 58 |
+
# Per-row title features
|
| 59 |
+
current_weight = [config.TITLE_GROUP_WEIGHT.get(g, 0.05) for g in current_groups]
|
| 60 |
+
is_target = [1.0 if (g in {"applied_ml", "retrieval_ranking", "nlp_llm"}) else 0.0 for g in current_groups]
|
| 61 |
+
is_services_or_noneng = [1.0 if g in {"non_target"} else 0.0 for g in current_groups]
|
| 62 |
+
is_data_platform = [1.0 if g in {"data_platform"} else 0.0 for g in current_groups]
|
| 63 |
+
is_data_science = [1.0 if g in {"data_science"} else 0.0 for g in current_groups]
|
| 64 |
+
is_generic_swe = [1.0 if g in {"generic_swe"} else 0.0 for g in current_groups]
|
| 65 |
+
|
| 66 |
+
# Promotion velocity: number of distinct titles / yoe (low if many short stints)
|
| 67 |
+
n_titles = []
|
| 68 |
+
for h in histories:
|
| 69 |
+
if h is None:
|
| 70 |
+
n_titles.append(0)
|
| 71 |
+
elif hasattr(h, "tolist"):
|
| 72 |
+
n_titles.append(len(h.tolist()))
|
| 73 |
+
else:
|
| 74 |
+
n_titles.append(len(h))
|
| 75 |
+
avg_tenure_months = []
|
| 76 |
+
for ch in df["career"].tolist():
|
| 77 |
+
if ch is None:
|
| 78 |
+
avg_tenure_months.append(0.0)
|
| 79 |
+
continue
|
| 80 |
+
if hasattr(ch, "tolist"):
|
| 81 |
+
ch = ch.tolist()
|
| 82 |
+
if not ch:
|
| 83 |
+
avg_tenure_months.append(0.0)
|
| 84 |
+
continue
|
| 85 |
+
durs = [int(c.get("duration_months") or 0) for c in (ch or [])]
|
| 86 |
+
durs = [d for d in durs if d > 0]
|
| 87 |
+
if durs:
|
| 88 |
+
avg_tenure_months.append(sum(durs) / len(durs))
|
| 89 |
+
else:
|
| 90 |
+
avg_tenure_months.append(0.0)
|
| 91 |
+
|
| 92 |
+
out = pd.DataFrame({
|
| 93 |
+
"title_weight": current_weight,
|
| 94 |
+
"title_history_max_weight": history_max_weight,
|
| 95 |
+
"is_target_title": is_target,
|
| 96 |
+
"is_noneng_title": is_services_or_noneng,
|
| 97 |
+
"is_data_platform": is_data_platform,
|
| 98 |
+
"is_data_science": is_data_science,
|
| 99 |
+
"is_generic_swe": is_generic_swe,
|
| 100 |
+
"n_career_titles": n_titles,
|
| 101 |
+
"avg_tenure_months": avg_tenure_months,
|
| 102 |
+
# blended title fit (current 70%, history 30%)
|
| 103 |
+
"title_fit_blend": [
|
| 104 |
+
0.7 * w + 0.3 * hw for w, hw in zip(current_weight, history_max_weight)
|
| 105 |
+
],
|
| 106 |
+
})
|
| 107 |
+
return out
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ---------------------------------------------------------------------------
|
| 111 |
+
# Company tier (0-3)
|
| 112 |
+
# ---------------------------------------------------------------------------
|
| 113 |
+
|
| 114 |
+
def _company_tier(name: str) -> int:
|
| 115 |
+
"""Map company name to tier. 3=FAANG+top-AI, 2=strong product, 1=other, 0=unknown."""
|
| 116 |
+
name = (name or "").strip()
|
| 117 |
+
if not name:
|
| 118 |
+
return 0
|
| 119 |
+
nl = name.lower()
|
| 120 |
+
for tier in (3, 2):
|
| 121 |
+
for c in config.COMPANY_TIER.get(tier, set()):
|
| 122 |
+
if c.lower() in nl or nl in c.lower():
|
| 123 |
+
return tier
|
| 124 |
+
# IT services
|
| 125 |
+
for svc in config.IT_SERVICES_PURE_PLAY:
|
| 126 |
+
if svc.lower() in nl:
|
| 127 |
+
return 0
|
| 128 |
+
return 1 # unknown product/startup
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def company_tier_features(df: pd.DataFrame) -> pd.DataFrame:
|
| 132 |
+
"""Company tier features: current tier, max tier across career, top-tier flags."""
|
| 133 |
+
def _tier_from_list(lst):
|
| 134 |
+
if lst is None:
|
| 135 |
+
return []
|
| 136 |
+
if hasattr(lst, "tolist"):
|
| 137 |
+
lst = lst.tolist()
|
| 138 |
+
return [_company_tier(c) for c in (lst or [])]
|
| 139 |
+
|
| 140 |
+
current_tier = [_company_tier(c) for c in df["current_company"].fillna("").tolist()]
|
| 141 |
+
max_tiers = [max(_tier_from_list(lst)) for lst in df["career_companies"].tolist()]
|
| 142 |
+
|
| 143 |
+
return pd.DataFrame({
|
| 144 |
+
"company_tier_current": current_tier,
|
| 145 |
+
"company_tier_max": max_tiers,
|
| 146 |
+
"is_top_tier_company": [1 if t >= 3 else 0 for t in max_tiers],
|
| 147 |
+
"is_product_company_v2": [1 if t >= 1 else 0 for t in max_tiers],
|
| 148 |
+
})
|
redrob/graph/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Heterogeneous graph: candidate-skill-title-company-industry."""
|
| 2 |
+
from .build import build_graph
|
| 3 |
+
from .propagate import personalised_pagerank, ppr_axes
|
| 4 |
+
from .coherence import career_coherence_scores, career_evidence_score, skill_community_features
|
| 5 |
+
|
| 6 |
+
__all__ = [
|
| 7 |
+
"build_graph",
|
| 8 |
+
"personalised_pagerank",
|
| 9 |
+
"ppr_axes",
|
| 10 |
+
"career_coherence_scores",
|
| 11 |
+
"career_evidence_score",
|
| 12 |
+
"skill_community_features",
|
| 13 |
+
]
|
redrob/graph/build.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build a heterogeneous networkx graph over the candidate pool.
|
| 2 |
+
|
| 3 |
+
Vectorised: we build long-format edge DataFrames and use
|
| 4 |
+
`nx.from_edgelist` to add edges in bulk. This is roughly 50x faster
|
| 5 |
+
than the iterrows approach on 100k candidates.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import pickle
|
| 10 |
+
from collections import defaultdict
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Dict, List, Tuple
|
| 13 |
+
|
| 14 |
+
import networkx as nx
|
| 15 |
+
import numpy as np
|
| 16 |
+
import pandas as pd
|
| 17 |
+
|
| 18 |
+
from .. import config
|
| 19 |
+
from ..data import load_or_build_parquet
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _safe_label(prefix: str, name: str) -> str:
|
| 23 |
+
name = (name or "").strip()
|
| 24 |
+
if not name:
|
| 25 |
+
name = "unknown"
|
| 26 |
+
return f"{prefix}::{name}"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _explode_lists(df: pd.DataFrame, col: str, prefix: str) -> pd.DataFrame:
|
| 30 |
+
"""Return a DataFrame with columns (candidate_id, neighbour)."""
|
| 31 |
+
s = df[["candidate_id", col]].copy()
|
| 32 |
+
s[col] = s[col].apply(lambda v: list(v) if v is not None else [])
|
| 33 |
+
s = s.explode(col).dropna()
|
| 34 |
+
s["neighbour"] = s[col].apply(lambda x: _safe_label(prefix, x))
|
| 35 |
+
s = s.drop(columns=[col])
|
| 36 |
+
s = s[s["neighbour"].str.split("::", n=1).str[1] != "unknown"]
|
| 37 |
+
return s
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def build_graph(force: bool = False) -> nx.Graph:
|
| 41 |
+
if not force and config.GRAPH_PICKLE.exists():
|
| 42 |
+
with open(config.GRAPH_PICKLE, "rb") as f:
|
| 43 |
+
return pickle.load(f)
|
| 44 |
+
|
| 45 |
+
df = load_or_build_parquet()
|
| 46 |
+
G = nx.Graph()
|
| 47 |
+
cand_ids = df["candidate_id"].tolist()
|
| 48 |
+
cand_nodes = [f"cand::{c}" for c in cand_ids]
|
| 49 |
+
G.add_nodes_from((n, {"kind": "candidate"}) for n in cand_nodes)
|
| 50 |
+
|
| 51 |
+
# Skill edges
|
| 52 |
+
print("building skill edges...")
|
| 53 |
+
skill_df = _explode_lists(df, "skill_names", "skill")
|
| 54 |
+
skill_edges = list(zip(skill_df["candidate_id"].map(lambda c: f"cand::{c}"),
|
| 55 |
+
skill_df["neighbour"]))
|
| 56 |
+
G.add_edges_from(skill_edges, kind="has_skill", weight=1.0)
|
| 57 |
+
# Capture unique skill nodes
|
| 58 |
+
print(f" skill edges: {len(skill_edges):,}")
|
| 59 |
+
|
| 60 |
+
# Title edges
|
| 61 |
+
print("building title edges...")
|
| 62 |
+
title_df = _explode_lists(df, "career_titles", "title")
|
| 63 |
+
title_edges = list(zip(title_df["candidate_id"].map(lambda c: f"cand::{c}"),
|
| 64 |
+
title_df["neighbour"]))
|
| 65 |
+
G.add_edges_from(title_edges, kind="held_title", weight=1.0)
|
| 66 |
+
print(f" title edges: {len(title_edges):,}")
|
| 67 |
+
|
| 68 |
+
# Company edges
|
| 69 |
+
print("building company edges...")
|
| 70 |
+
comp_df = _explode_lists(df, "career_companies", "company")
|
| 71 |
+
comp_edges = list(zip(comp_df["candidate_id"].map(lambda c: f"cand::{c}"),
|
| 72 |
+
comp_df["neighbour"]))
|
| 73 |
+
G.add_edges_from(comp_edges, kind="worked_at", weight=1.0)
|
| 74 |
+
print(f" company edges: {len(comp_edges):,}")
|
| 75 |
+
|
| 76 |
+
# Industry edges
|
| 77 |
+
print("building industry edges...")
|
| 78 |
+
ind_df = _explode_lists(df, "career_industries", "industry")
|
| 79 |
+
ind_edges = list(zip(ind_df["candidate_id"].map(lambda c: f"cand::{c}"),
|
| 80 |
+
ind_df["neighbour"]))
|
| 81 |
+
G.add_edges_from(ind_edges, kind="in_industry", weight=1.0)
|
| 82 |
+
print(f" industry edges: {len(ind_edges):,}")
|
| 83 |
+
|
| 84 |
+
# Skill-skill co-occurrence within same candidate
|
| 85 |
+
print("building skill cooccurrence edges...")
|
| 86 |
+
cand_skill: Dict[str, List[str]] = defaultdict(list)
|
| 87 |
+
for cid, sn in skill_edges:
|
| 88 |
+
cand_skill[cid].append(sn)
|
| 89 |
+
cooccur_counts: Dict[Tuple[str, str], int] = defaultdict(int)
|
| 90 |
+
for c, skills in cand_skill.items():
|
| 91 |
+
# dedupe within candidate
|
| 92 |
+
skills = list(set(skills))
|
| 93 |
+
for i in range(len(skills)):
|
| 94 |
+
for j in range(i + 1, len(skills)):
|
| 95 |
+
a, b = skills[i], skills[j]
|
| 96 |
+
if a == b:
|
| 97 |
+
continue
|
| 98 |
+
if a > b:
|
| 99 |
+
a, b = b, a
|
| 100 |
+
cooccur_counts[(a, b)] += 1
|
| 101 |
+
cooccur_edges = [(a, b, {"kind": "skill_cooccur", "weight": float(c)})
|
| 102 |
+
for (a, b), c in cooccur_counts.items()]
|
| 103 |
+
G.add_edges_from(cooccur_edges)
|
| 104 |
+
print(f" cooccurrence edges: {len(cooccur_edges):,}")
|
| 105 |
+
|
| 106 |
+
with open(config.GRAPH_PICKLE, "wb") as f:
|
| 107 |
+
pickle.dump(G, f, protocol=pickle.HIGHEST_PROTOCOL)
|
| 108 |
+
return G
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
if __name__ == "__main__":
|
| 112 |
+
import sys, time
|
| 113 |
+
t = time.time()
|
| 114 |
+
G = build_graph(force=True)
|
| 115 |
+
print(f"graph built: {G.number_of_nodes():,} nodes, {G.number_of_edges():,} edges in {time.time()-t:.1f}s")
|
redrob/graph/coherence.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Career coherence and skill community features."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import re
|
| 5 |
+
from collections import defaultdict
|
| 6 |
+
from typing import Dict, List, Tuple
|
| 7 |
+
|
| 8 |
+
import networkx as nx
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
|
| 12 |
+
from .. import config
|
| 13 |
+
from .build import build_graph
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# --- title embeddings (cached) -------------------------------------------------
|
| 17 |
+
|
| 18 |
+
def _title_centroid_embeddings() -> Dict[str, np.ndarray]:
|
| 19 |
+
"""Encode every unique title once. Uses sentence-transformers if available,
|
| 20 |
+
otherwise a one-hot over known role groups.
|
| 21 |
+
"""
|
| 22 |
+
if config.TITLE_EMB_NPY.exists():
|
| 23 |
+
try:
|
| 24 |
+
with open(str(config.TITLE_EMB_NPY) + ".ids.txt", "r", encoding="utf-8") as f:
|
| 25 |
+
ids = [line.strip() for line in f if line.strip()]
|
| 26 |
+
arr = np.load(config.TITLE_EMB_NPY)
|
| 27 |
+
return {ids[i]: arr[i] for i in range(len(ids))}
|
| 28 |
+
except Exception:
|
| 29 |
+
pass
|
| 30 |
+
|
| 31 |
+
from ..data import load_or_build_parquet
|
| 32 |
+
df = load_or_build_parquet()
|
| 33 |
+
titles = sorted({(t or "").strip() for t in df["career_titles"].explode().tolist() if t})
|
| 34 |
+
try:
|
| 35 |
+
from sentence_transformers import SentenceTransformer # type: ignore
|
| 36 |
+
from ..retrieval.dense import _load_model
|
| 37 |
+
model = _load_model()
|
| 38 |
+
embs = model.encode(
|
| 39 |
+
titles,
|
| 40 |
+
batch_size=128,
|
| 41 |
+
show_progress_bar=False,
|
| 42 |
+
convert_to_numpy=True,
|
| 43 |
+
normalize_embeddings=True,
|
| 44 |
+
).astype(np.float32)
|
| 45 |
+
except Exception:
|
| 46 |
+
# Deterministic fallback: bag-of-words over known role keywords
|
| 47 |
+
vocab: Dict[str, int] = {}
|
| 48 |
+
for t in titles:
|
| 49 |
+
for tok in re.findall(r"[a-z]+", t.lower()):
|
| 50 |
+
if tok not in vocab:
|
| 51 |
+
vocab[tok] = len(vocab)
|
| 52 |
+
embs = np.zeros((len(titles), max(len(vocab), 1)), dtype=np.float32)
|
| 53 |
+
for i, t in enumerate(titles):
|
| 54 |
+
for tok in re.findall(r"[a-z]+", t.lower()):
|
| 55 |
+
embs[i, vocab[tok]] = 1.0
|
| 56 |
+
n = np.linalg.norm(embs, axis=1, keepdims=True) + 1e-8
|
| 57 |
+
embs = embs / n
|
| 58 |
+
|
| 59 |
+
np.save(config.TITLE_EMB_NPY, embs)
|
| 60 |
+
with open(str(config.TITLE_EMB_NPY) + ".ids.txt", "w", encoding="utf-8") as f:
|
| 61 |
+
f.write("\n".join(titles))
|
| 62 |
+
return {titles[i]: embs[i] for i in range(len(titles))}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def career_coherence_scores(df: pd.DataFrame) -> np.ndarray:
|
| 66 |
+
"""Mean cosine similarity of consecutive title embeddings for each row."""
|
| 67 |
+
embs = _title_centroid_embeddings()
|
| 68 |
+
out = np.zeros(len(df), dtype=np.float32)
|
| 69 |
+
df_reset = df.reset_index(drop=True)
|
| 70 |
+
for i in range(len(df_reset)):
|
| 71 |
+
titles = df_reset.iloc[i]["career_titles"]
|
| 72 |
+
if titles is None:
|
| 73 |
+
titles = []
|
| 74 |
+
if hasattr(titles, "tolist"):
|
| 75 |
+
titles = titles.tolist()
|
| 76 |
+
if not titles or len(titles) < 2:
|
| 77 |
+
out[i] = 0.5 # neutral default for single-title records
|
| 78 |
+
continue
|
| 79 |
+
vecs = []
|
| 80 |
+
for t in titles:
|
| 81 |
+
v = embs.get((t or "").strip())
|
| 82 |
+
if v is not None:
|
| 83 |
+
vecs.append(v)
|
| 84 |
+
if len(vecs) < 2:
|
| 85 |
+
out[i] = 0.5
|
| 86 |
+
continue
|
| 87 |
+
sims = []
|
| 88 |
+
for j in range(1, len(vecs)):
|
| 89 |
+
s = float(np.dot(vecs[j - 1], vecs[j]))
|
| 90 |
+
sims.append(s)
|
| 91 |
+
out[i] = float(np.mean(sims))
|
| 92 |
+
return out
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# --- skill community features (Louvain over skill-skill subgraph) ------------
|
| 96 |
+
|
| 97 |
+
def _louvain_communities(G: nx.Graph) -> Dict[str, int]:
|
| 98 |
+
try:
|
| 99 |
+
from networkx.algorithms.community import louvain_communities # type: ignore
|
| 100 |
+
comms = louvain_communities(G, weight="weight", resolution=1.0, seed=42)
|
| 101 |
+
out: Dict[str, int] = {}
|
| 102 |
+
for idx, c in enumerate(comms):
|
| 103 |
+
for n in c:
|
| 104 |
+
out[n] = idx
|
| 105 |
+
return out
|
| 106 |
+
except Exception:
|
| 107 |
+
# Greedy modularity fallback
|
| 108 |
+
try:
|
| 109 |
+
from networkx.algorithms.community import greedy_modularity_communities
|
| 110 |
+
comms = list(greedy_modularity_communities(G, weight="weight"))
|
| 111 |
+
out = {}
|
| 112 |
+
for idx, c in enumerate(comms):
|
| 113 |
+
for n in c:
|
| 114 |
+
out[n] = idx
|
| 115 |
+
return out
|
| 116 |
+
except Exception:
|
| 117 |
+
return {}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def skill_community_features(df: pd.DataFrame) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 121 |
+
"""Return (community_purity, skill_rarity, graph_degree) per row.
|
| 122 |
+
|
| 123 |
+
- community_purity: fraction of candidate's skills that share a community
|
| 124 |
+
with at least one JD-core skill.
|
| 125 |
+
- skill_rarity: sum of inverse-skill-frequency * jd_match over skills.
|
| 126 |
+
- graph_degree: log1p of candidate's degree in the candidate-skill subgraph.
|
| 127 |
+
"""
|
| 128 |
+
G = build_graph()
|
| 129 |
+
skill_nodes = [n for n, d in G.nodes(data=True) if d.get("kind") == "skill"]
|
| 130 |
+
skill_subg = G.subgraph(skill_nodes).copy()
|
| 131 |
+
communities = _louvain_communities(skill_subg)
|
| 132 |
+
|
| 133 |
+
jd_skill_nodes = {f"skill::{t.strip()}" for t in config.CORE_COMPETENCIES}
|
| 134 |
+
jd_community_ids = {communities.get(n) for n in jd_skill_nodes if n in communities and communities.get(n) is not None}
|
| 135 |
+
jd_community_ids.discard(None)
|
| 136 |
+
|
| 137 |
+
# Skill frequency (for rarity)
|
| 138 |
+
skill_freq: Dict[str, int] = defaultdict(int)
|
| 139 |
+
for u, v, d in G.edges(data=True):
|
| 140 |
+
if d.get("kind") == "has_skill":
|
| 141 |
+
skill_freq[v] += 1
|
| 142 |
+
total_cands = max(1, len(df))
|
| 143 |
+
idf = {s: float(np.log((1 + total_cands) / (1 + f))) for s, f in skill_freq.items()}
|
| 144 |
+
|
| 145 |
+
purity = np.zeros(len(df), dtype=np.float32)
|
| 146 |
+
rarity = np.zeros(len(df), dtype=np.float32)
|
| 147 |
+
degree = np.zeros(len(df), dtype=np.float32)
|
| 148 |
+
|
| 149 |
+
df_reset = df.reset_index(drop=True)
|
| 150 |
+
for i, row in df_reset.iterrows():
|
| 151 |
+
skill_names = row["skill_names"]
|
| 152 |
+
if skill_names is None:
|
| 153 |
+
skill_names = []
|
| 154 |
+
if hasattr(skill_names, "tolist"):
|
| 155 |
+
skill_names = skill_names.tolist()
|
| 156 |
+
skills = [f"skill::{(s or '').strip()}" for s in skill_names if s]
|
| 157 |
+
if not skills:
|
| 158 |
+
continue
|
| 159 |
+
n = len(skills)
|
| 160 |
+
n_jd_aligned = 0
|
| 161 |
+
rsum = 0.0
|
| 162 |
+
for s in skills:
|
| 163 |
+
if s in communities and communities[s] in jd_community_ids:
|
| 164 |
+
n_jd_aligned += 1
|
| 165 |
+
rsum += idf.get(s, 0.0)
|
| 166 |
+
purity[i] = n_jd_aligned / n
|
| 167 |
+
rarity[i] = rsum / n
|
| 168 |
+
cnode = f"cand::{row['candidate_id']}"
|
| 169 |
+
deg = G.degree(cnode) if cnode in G else 0
|
| 170 |
+
degree[i] = float(np.log1p(deg))
|
| 171 |
+
return purity, rarity, degree
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
if __name__ == "__main__":
|
| 175 |
+
from ..data import load_or_build_parquet
|
| 176 |
+
df = load_or_build_parquet().head(1000)
|
| 177 |
+
coh = career_coherence_scores(df)
|
| 178 |
+
purity, rarity, deg = skill_community_features(df)
|
| 179 |
+
print("coherence range:", coh.min(), coh.max())
|
| 180 |
+
print("purity range:", purity.min(), purity.max())
|
| 181 |
+
print("rarity range:", rarity.min(), rarity.max())
|
| 182 |
+
print("degree range:", deg.min(), deg.max())
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# ---------------------------------------------------------------------------
|
| 186 |
+
# Career evidence score: lexical signal for "shipped ranking/search/recsys"
|
| 187 |
+
# ---------------------------------------------------------------------------
|
| 188 |
+
|
| 189 |
+
import re as _re
|
| 190 |
+
|
| 191 |
+
_SHIP_RE = _re.compile(
|
| 192 |
+
r"\b(built|shipped|launched|migrated|scaled|deployed|productionized|"
|
| 193 |
+
r"productionised|architected|led|owned|drove|designed|implemented|"
|
| 194 |
+
r"integrated|rolled out|from zero|to millions|million users|100x|10x)\b",
|
| 195 |
+
_re.IGNORECASE,
|
| 196 |
+
)
|
| 197 |
+
_RETRIEVAL_RE = _re.compile(
|
| 198 |
+
r"\b(ranking|ranker|search|retrieval|recommendation|recommender|recsys|"
|
| 199 |
+
r"ranking system|search system|recommendation system|vector search|"
|
| 200 |
+
r"hybrid search|elasticsearch|opensearch|faiss|pinecone|weaviate|qdrant|"
|
| 201 |
+
r"milvus|bm25|embeddings|semantic|learning to rank|learning-to-rank|"
|
| 202 |
+
r"lambda|lambdarank|ndcg|mrr|map|a/?b|ab test|ab testing|online evaluation|"
|
| 203 |
+
r"offline evaluation|click.?through|ctr|engagement)\b",
|
| 204 |
+
_re.IGNORECASE,
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def career_evidence_score(df: pd.DataFrame) -> np.ndarray:
|
| 209 |
+
"""Lexical score for 'shipped ranking/search/recsys at scale' evidence.
|
| 210 |
+
|
| 211 |
+
JD says: "has shipped at least one end-to-end ranking, search, or
|
| 212 |
+
recommendation system to real users at meaningful scale."
|
| 213 |
+
We check career descriptions for ship verbs AND retrieval keywords.
|
| 214 |
+
"""
|
| 215 |
+
out = np.zeros(len(df), dtype=np.float32)
|
| 216 |
+
df_r = df.reset_index(drop=True)
|
| 217 |
+
|
| 218 |
+
def _as_list(v):
|
| 219 |
+
if v is None:
|
| 220 |
+
return []
|
| 221 |
+
if hasattr(v, "tolist"):
|
| 222 |
+
v = v.tolist()
|
| 223 |
+
return v if isinstance(v, list) else []
|
| 224 |
+
|
| 225 |
+
for i in range(len(df_r)):
|
| 226 |
+
career = _as_list(df_r.iloc[i].get("career"))
|
| 227 |
+
if not career:
|
| 228 |
+
out[i] = 0.0
|
| 229 |
+
continue
|
| 230 |
+
# Check last 3 roles
|
| 231 |
+
recent = career[-3:]
|
| 232 |
+
n_ship = 0
|
| 233 |
+
n_ret = 0
|
| 234 |
+
total = len(recent)
|
| 235 |
+
for ch in recent:
|
| 236 |
+
desc = ((ch.get("description") or "") + " " + (ch.get("title") or "")).lower()
|
| 237 |
+
if _SHIP_RE.search(desc):
|
| 238 |
+
n_ship += 1
|
| 239 |
+
if _RETRIEVAL_RE.search(desc):
|
| 240 |
+
n_ret += 1
|
| 241 |
+
if total == 0:
|
| 242 |
+
out[i] = 0.0
|
| 243 |
+
continue
|
| 244 |
+
# Score: system match (60%) + ship verb (40%), averaged over roles
|
| 245 |
+
out[i] = float(min(1.0, 0.6 * (n_ret / total) * 2 + 0.4 * (n_ship / total) * 2))
|
| 246 |
+
return out
|
redrob/graph/propagate.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Personalised PageRank over the heterogeneous graph.
|
| 2 |
+
|
| 3 |
+
Supports both the original single-axis PPR and a multi-axis PPR
|
| 4 |
+
(5 JD axes: applied_ml, retrieval_rank, nlp_llm, production_eng, product_company).
|
| 5 |
+
Multi-axis results are cached on disk at artifacts/ppr_axes.pkl.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import pickle
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Dict, Iterable, List
|
| 12 |
+
|
| 13 |
+
import networkx as nx
|
| 14 |
+
|
| 15 |
+
from .. import config
|
| 16 |
+
from .build import build_graph
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _seed_nodes(jd_terms: Iterable[str], prefix: str) -> List[str]:
|
| 20 |
+
"""Map JD terms to graph node labels with the given prefix."""
|
| 21 |
+
out: List[str] = []
|
| 22 |
+
for term in jd_terms:
|
| 23 |
+
key = f"{prefix}::{term.strip()}"
|
| 24 |
+
out.append(key)
|
| 25 |
+
return out
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def personalised_pagerank(
|
| 29 |
+
jd_terms: Iterable[str] | None = None,
|
| 30 |
+
alpha: float = 0.85,
|
| 31 |
+
max_iter: int = 60,
|
| 32 |
+
) -> Dict[str, float]:
|
| 33 |
+
"""Return a dict of {candidate_node: ppr_score}."""
|
| 34 |
+
G = build_graph()
|
| 35 |
+
seeds = _seed_nodes(jd_terms or config.CORE_COMPETENCIES, "skill")
|
| 36 |
+
seeds = [s for s in seeds if s in G]
|
| 37 |
+
if not seeds:
|
| 38 |
+
return {}
|
| 39 |
+
personalization = {n: 0.0 for n in G.nodes()}
|
| 40 |
+
for s in seeds:
|
| 41 |
+
personalization[s] = 1.0 / len(seeds)
|
| 42 |
+
pr = nx.pagerank(
|
| 43 |
+
G,
|
| 44 |
+
alpha=alpha,
|
| 45 |
+
personalization=personalization,
|
| 46 |
+
max_iter=max_iter,
|
| 47 |
+
tol=1e-6,
|
| 48 |
+
weight="weight",
|
| 49 |
+
)
|
| 50 |
+
return {n: float(v) for n, v in pr.items()
|
| 51 |
+
if isinstance(n, str) and n.startswith("cand::")}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# Disk cache path for multi-axis PPR
|
| 55 |
+
_PPR_AXES_CACHE = config.ARTIFACTS_DIR / "ppr_axes.pkl"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def ppr_axes(
|
| 59 |
+
axes: Dict[str, List[str]] | None = None,
|
| 60 |
+
alpha: float = 0.85,
|
| 61 |
+
max_iter: int = 60,
|
| 62 |
+
) -> Dict[str, Dict[str, float]]:
|
| 63 |
+
"""Multi-axis PPR: one PPR per JD axis.
|
| 64 |
+
|
| 65 |
+
Returns Dict[axis_name, Dict[cand_node, ppr_score]].
|
| 66 |
+
Results are cached at artifacts/ppr_axes.pkl.
|
| 67 |
+
|
| 68 |
+
Axes:
|
| 69 |
+
applied_ml -> seeds from config.JD_AXES["applied_ml"] on skill:: nodes
|
| 70 |
+
retrieval_rank -> seeds from config.JD_AXES["retrieval_rank"] on skill:: nodes
|
| 71 |
+
nlp_llm -> seeds from config.JD_AXES["nlp_llm"] on skill:: nodes
|
| 72 |
+
production_eng -> seeds from config.JD_AXES["production_eng"] on skill:: nodes
|
| 73 |
+
product_company -> seeds from config.JD_AXES["product_company"] on company:: nodes
|
| 74 |
+
"""
|
| 75 |
+
if axes is None:
|
| 76 |
+
axes = config.JD_AXES
|
| 77 |
+
|
| 78 |
+
# Load cache if present
|
| 79 |
+
if _PPR_AXES_CACHE.exists():
|
| 80 |
+
with open(_PPR_AXES_CACHE, "rb") as f:
|
| 81 |
+
cached: Dict[str, Dict[str, float]] = pickle.load(f)
|
| 82 |
+
# Validate all axes are present
|
| 83 |
+
if all(ax in cached for ax in axes):
|
| 84 |
+
return cached
|
| 85 |
+
|
| 86 |
+
G = build_graph()
|
| 87 |
+
result: Dict[str, Dict[str, float]] = {}
|
| 88 |
+
|
| 89 |
+
for axis_name, terms in axes.items():
|
| 90 |
+
# product_company axis uses company:: nodes; all others use skill::
|
| 91 |
+
prefix = "company" if axis_name == "product_company" else "skill"
|
| 92 |
+
seeds = _seed_nodes(terms, prefix)
|
| 93 |
+
seeds = [s for s in seeds if s in G]
|
| 94 |
+
if not seeds:
|
| 95 |
+
result[axis_name] = {}
|
| 96 |
+
print(f" ppr_axes[{axis_name}]: 0 seeds found in graph")
|
| 97 |
+
continue
|
| 98 |
+
personalization = {n: 0.0 for n in G.nodes()}
|
| 99 |
+
for s in seeds:
|
| 100 |
+
personalization[s] = 1.0 / len(seeds)
|
| 101 |
+
pr = nx.pagerank(
|
| 102 |
+
G,
|
| 103 |
+
alpha=alpha,
|
| 104 |
+
personalization=personalization,
|
| 105 |
+
max_iter=max_iter,
|
| 106 |
+
tol=1e-6,
|
| 107 |
+
weight="weight",
|
| 108 |
+
)
|
| 109 |
+
result[axis_name] = {
|
| 110 |
+
n: float(v)
|
| 111 |
+
for n, v in pr.items()
|
| 112 |
+
if isinstance(n, str) and n.startswith("cand::")
|
| 113 |
+
}
|
| 114 |
+
top_score = max(result[axis_name].values()) if result[axis_name] else 0.0
|
| 115 |
+
print(f" ppr_axes[{axis_name}]: {len(seeds)} seeds, top_score={top_score:.6f}")
|
| 116 |
+
|
| 117 |
+
# Persist to disk
|
| 118 |
+
with open(_PPR_AXES_CACHE, "wb") as f:
|
| 119 |
+
pickle.dump(result, f, protocol=pickle.HIGHEST_PROTOCOL)
|
| 120 |
+
|
| 121 |
+
return result
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
if __name__ == "__main__":
|
| 125 |
+
axes = ppr_axes()
|
| 126 |
+
for name, scores in axes.items():
|
| 127 |
+
top = sorted(scores.items(), key=lambda x: -x[1])[:3]
|
| 128 |
+
print(f"{name}: {top}")
|
redrob/rank/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Learning-to-rank with LightGBM LambdaRank and a deterministic fallback."""
|
| 2 |
+
from .train import (
|
| 3 |
+
build_feature_frame,
|
| 4 |
+
weak_relevance_label,
|
| 5 |
+
train_ranker,
|
| 6 |
+
deterministic_score,
|
| 7 |
+
)
|
| 8 |
+
from .predict import rank_shortlist
|
| 9 |
+
|
| 10 |
+
__all__ = [
|
| 11 |
+
"build_feature_frame",
|
| 12 |
+
"weak_relevance_label",
|
| 13 |
+
"train_ranker",
|
| 14 |
+
"deterministic_score",
|
| 15 |
+
"rank_shortlist",
|
| 16 |
+
]
|
redrob/rank/predict.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Apply the trained ranker to a shortlist and return the final ranking.
|
| 2 |
+
|
| 3 |
+
Architecture: deterministic-first, with the LTR as a local tiebreaker.
|
| 4 |
+
|
| 5 |
+
The deterministic composite (see redrob/rank/train.py:deterministic_score)
|
| 6 |
+
was validated empirically to surface 80 elites in top-100, vs 55 for the
|
| 7 |
+
LTR-first approach. The LTR is preserved as a refiner that only reorders
|
| 8 |
+
near-tied deterministic buckets.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from typing import List, Optional, Tuple
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
import pandas as pd
|
| 16 |
+
|
| 17 |
+
from .. import config
|
| 18 |
+
from .train import (
|
| 19 |
+
FEATURE_COLUMNS,
|
| 20 |
+
build_feature_frame,
|
| 21 |
+
deterministic_score,
|
| 22 |
+
train_ranker,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# Local-tiebreak threshold (deterministic-score units). Within this band,
|
| 27 |
+
# the LTR refines the ordering; outside it, the deterministic score is the
|
| 28 |
+
# primary key.
|
| 29 |
+
LTR_REFINE_EPS = 0.15
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _load_model() -> Tuple[Optional[object], List[str]]:
|
| 33 |
+
try:
|
| 34 |
+
return train_ranker(force=False)
|
| 35 |
+
except Exception:
|
| 36 |
+
return None, FEATURE_COLUMNS
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def rank_shortlist(
|
| 40 |
+
df: pd.DataFrame,
|
| 41 |
+
use_lgbm: bool = True,
|
| 42 |
+
) -> pd.DataFrame:
|
| 43 |
+
"""Rank a shortlist dataframe.
|
| 44 |
+
|
| 45 |
+
Returns the dataframe with `model_score`, `det_score`, `final_score`,
|
| 46 |
+
and `honeypot_excluded` columns.
|
| 47 |
+
|
| 48 |
+
Sort order:
|
| 49 |
+
1) Honeypot hard-excluded candidates pushed to the bottom.
|
| 50 |
+
2) Primary sort by deterministic_score (proven composite).
|
| 51 |
+
3) Within near-tied deterministic buckets (Δ < LTR_REFINE_EPS),
|
| 52 |
+
re-sort by LTR score. Otherwise keep deterministic order.
|
| 53 |
+
4) Final tiebreaker: candidate_id ascending.
|
| 54 |
+
"""
|
| 55 |
+
df = build_feature_frame(df)
|
| 56 |
+
|
| 57 |
+
# Always compute deterministic score
|
| 58 |
+
det = deterministic_score(df)
|
| 59 |
+
df["det_score"] = det
|
| 60 |
+
df["model_score"] = det # for backwards-compat callers
|
| 61 |
+
|
| 62 |
+
# Optionally compute LTR score
|
| 63 |
+
ltr_score: Optional[np.ndarray] = None
|
| 64 |
+
if use_lgbm:
|
| 65 |
+
model, feats = _load_model()
|
| 66 |
+
if model is not None and "sparse_score" in df.columns:
|
| 67 |
+
# Only run the model if retrieval features are non-zero
|
| 68 |
+
X = df[feats].fillna(0).astype(np.float32)
|
| 69 |
+
ltr_score = np.asarray(model.predict(X), dtype=np.float32)
|
| 70 |
+
|
| 71 |
+
# Honeypot hard-exclusion flag
|
| 72 |
+
df["honeypot_excluded"] = df["honeypot_penalty"] >= config.HONEYPOT_HARD_EXCLUDE
|
| 73 |
+
|
| 74 |
+
# final_score starts as the deterministic score; honeypot-hard → -1e9
|
| 75 |
+
df["final_score"] = det.astype(np.float32).copy()
|
| 76 |
+
df.loc[df["honeypot_excluded"], "final_score"] = -1e9
|
| 77 |
+
|
| 78 |
+
# Sort: hard-excluded at bottom, then deterministic-primary, LTR-tiebreak,
|
| 79 |
+
# candidate_id-asc final tiebreak.
|
| 80 |
+
df = _sort_with_ltr_refine(df, ltr_score)
|
| 81 |
+
return df
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _sort_with_ltr_refine(
|
| 85 |
+
df: pd.DataFrame,
|
| 86 |
+
ltr_score: Optional[np.ndarray],
|
| 87 |
+
) -> pd.DataFrame:
|
| 88 |
+
"""Stable sort with LTR refinement of near-tied deterministic buckets."""
|
| 89 |
+
n = len(df)
|
| 90 |
+
if n == 0:
|
| 91 |
+
return df.reset_index(drop=True)
|
| 92 |
+
|
| 93 |
+
# Bucket key: the integer floor of det_score / LTR_REFINE_EPS.
|
| 94 |
+
# All candidates whose det scores fall in the same bucket are eligible
|
| 95 |
+
# for LTR-based refinement.
|
| 96 |
+
eps = float(LTR_REFINE_EPS)
|
| 97 |
+
if eps <= 0:
|
| 98 |
+
eps = 0.15
|
| 99 |
+
|
| 100 |
+
# Build a stable sort key with two parts:
|
| 101 |
+
# primary: det_score descending
|
| 102 |
+
# secondary: (within-bucket rank from LTR) descending
|
| 103 |
+
# tertiary: candidate_id ascending
|
| 104 |
+
if ltr_score is None or len(ltr_score) != n:
|
| 105 |
+
# No LTR: just sort by final_score then candidate_id.
|
| 106 |
+
return df.sort_values(
|
| 107 |
+
by=["final_score", "candidate_id"],
|
| 108 |
+
ascending=[False, True],
|
| 109 |
+
kind="mergesort",
|
| 110 |
+
).reset_index(drop=True)
|
| 111 |
+
|
| 112 |
+
# Compute the "LTR rank within bucket". For each candidate, find the
|
| 113 |
+
# set of other candidates within ±eps of its det_score; within that
|
| 114 |
+
# set, the LTR score determines order.
|
| 115 |
+
det = df["det_score"].to_numpy()
|
| 116 |
+
# Sort indices by det descending; ties broken by ltr descending then id asc.
|
| 117 |
+
# We do this with a stable sort over a composite key.
|
| 118 |
+
# composite_key = (det_bucket, -ltr, id) → ascending sort gives the right order
|
| 119 |
+
# bucket = floor(det / eps). We want highest det first → use -bucket.
|
| 120 |
+
bucket = np.floor(det / eps).astype(np.int64)
|
| 121 |
+
order = np.lexsort((
|
| 122 |
+
df["candidate_id"].to_numpy(), # tiebreaker 3: candidate_id asc
|
| 123 |
+
-ltr_score, # tiebreaker 2: ltr desc
|
| 124 |
+
-bucket, # primary: bucket desc (i.e., det desc)
|
| 125 |
+
))
|
| 126 |
+
return df.iloc[order].reset_index(drop=True)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
if __name__ == "__main__":
|
| 130 |
+
from ..data import load_or_build_parquet
|
| 131 |
+
df = load_or_build_parquet().head(200)
|
| 132 |
+
out = rank_shortlist(df)
|
| 133 |
+
print(out[["candidate_id", "current_title", "title_fit_blend", "det_score",
|
| 134 |
+
"model_score", "final_score", "honeypot_penalty"]].head(20))
|
redrob/rank/train.py
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Feature assembly, weak labels, and ranker training.
|
| 2 |
+
|
| 3 |
+
The ranker is a LightGBM LambdaRank model trained on synthetic weak labels
|
| 4 |
+
derived from the role blueprint. A deterministic scoring function is also
|
| 5 |
+
exposed as a robust fallback that does not depend on the trained model.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import math
|
| 11 |
+
import re
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import List, Tuple
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import pandas as pd
|
| 17 |
+
|
| 18 |
+
from .. import config
|
| 19 |
+
from ..blueprint import build_blueprint
|
| 20 |
+
from ..data import load_or_build_parquet
|
| 21 |
+
from ..graph.coherence import career_coherence_scores, career_evidence_score, skill_community_features
|
| 22 |
+
from ..graph.propagate import personalised_pagerank, ppr_axes
|
| 23 |
+
from ..features import (
|
| 24 |
+
title_fit_features,
|
| 25 |
+
company_tier_features,
|
| 26 |
+
recruitability_features,
|
| 27 |
+
honeypot_features,
|
| 28 |
+
skill_features,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
# Feature assembly
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
|
| 36 |
+
def build_feature_frame(df: pd.DataFrame) -> pd.DataFrame:
|
| 37 |
+
"""Compute every feature the ranker needs for the given dataframe."""
|
| 38 |
+
bp = build_blueprint()
|
| 39 |
+
|
| 40 |
+
# 1) Graph PPR over the full 100k pool (cached inside the graph module)
|
| 41 |
+
ppr = personalised_pagerank()
|
| 42 |
+
df = df.copy()
|
| 43 |
+
df["ppr_score"] = df["candidate_id"].map(lambda c: ppr.get(f"cand::{c}", 0.0))
|
| 44 |
+
|
| 45 |
+
# 1b) Multi-axis PPR: 5 specialised axes for better discrimination
|
| 46 |
+
ppr_ax = ppr_axes()
|
| 47 |
+
for axis_name, axis_scores in ppr_ax.items():
|
| 48 |
+
df[f"ppr_{axis_name}"] = df["candidate_id"].map(
|
| 49 |
+
lambda c, ax=axis_scores: ax.get(f"cand::{c}", 0.0)
|
| 50 |
+
)
|
| 51 |
+
# Derived: best axis score, mean, and std (breadth of fit)
|
| 52 |
+
ppr_axis_cols = [f"ppr_{a}" for a in ppr_ax.keys()]
|
| 53 |
+
if ppr_axis_cols:
|
| 54 |
+
ppr_vals = df[ppr_axis_cols].astype(float)
|
| 55 |
+
df["ppr_max_axis"] = ppr_vals.max(axis=1)
|
| 56 |
+
df["ppr_axis_mean"] = ppr_vals.mean(axis=1)
|
| 57 |
+
df["ppr_axis_std"] = ppr_vals.std(axis=1)
|
| 58 |
+
|
| 59 |
+
# Log-scaled PPR features (raw values are ~1e-6, invisible to the LTR)
|
| 60 |
+
df["ppr_score_log"] = np.log1p(df["ppr_score"].astype(float).to_numpy() * 1e5)
|
| 61 |
+
for col in ppr_axis_cols + ["ppr_max_axis", "ppr_axis_mean", "ppr_axis_std"]:
|
| 62 |
+
if col in df.columns:
|
| 63 |
+
df[f"{col}_log"] = np.log1p(df[col].astype(float).to_numpy() * 1e5)
|
| 64 |
+
|
| 65 |
+
# 2) Skill community features
|
| 66 |
+
purity, rarity, degree = skill_community_features(df)
|
| 67 |
+
df["skill_community_purity"] = purity
|
| 68 |
+
df["skill_rarity"] = rarity
|
| 69 |
+
df["graph_degree"] = degree
|
| 70 |
+
|
| 71 |
+
# 3) Career coherence
|
| 72 |
+
df["career_coherence"] = career_coherence_scores(df)
|
| 73 |
+
|
| 74 |
+
# 3b) Career evidence: shipped ranking/search/recsys signal
|
| 75 |
+
df["career_evidence"] = career_evidence_score(df)
|
| 76 |
+
|
| 77 |
+
# 4) Title features
|
| 78 |
+
tfeat = title_fit_features(df).reset_index(drop=True)
|
| 79 |
+
df = pd.concat([df.reset_index(drop=True), tfeat], axis=1)
|
| 80 |
+
|
| 81 |
+
# 4b) Company tier
|
| 82 |
+
ctier = company_tier_features(df).reset_index(drop=True)
|
| 83 |
+
df = pd.concat([df.reset_index(drop=True), ctier], axis=1)
|
| 84 |
+
|
| 85 |
+
# 5) Behavioral
|
| 86 |
+
bfeat = recruitability_features(df).reset_index(drop=True)
|
| 87 |
+
df = pd.concat([df, bfeat], axis=1)
|
| 88 |
+
|
| 89 |
+
# 6) Honeypot
|
| 90 |
+
hfeat = honeypot_features(df).reset_index(drop=True)
|
| 91 |
+
df = pd.concat([df, hfeat], axis=1)
|
| 92 |
+
|
| 93 |
+
# 7) Skill features
|
| 94 |
+
sfeat = skill_features(df).reset_index(drop=True)
|
| 95 |
+
df = pd.concat([df, sfeat], axis=1)
|
| 96 |
+
|
| 97 |
+
# 7b) Negative-specification flags (JD "do NOT want") — after skill features for n_core_skills
|
| 98 |
+
yoe_arr = df["yoe"].astype(float).to_numpy()
|
| 99 |
+
|
| 100 |
+
def _as_list_local(v):
|
| 101 |
+
if v is None:
|
| 102 |
+
return []
|
| 103 |
+
if hasattr(v, "tolist"):
|
| 104 |
+
return v.tolist()
|
| 105 |
+
return v if isinstance(v, list) else []
|
| 106 |
+
|
| 107 |
+
# Compute avg tenure and n_titles directly
|
| 108 |
+
avg_ten_arr = np.zeros(len(df), dtype=float)
|
| 109 |
+
n_tit_arr = np.zeros(len(df), dtype=np.int32)
|
| 110 |
+
is_consulting_only_arr = np.zeros(len(df), dtype=np.float32)
|
| 111 |
+
for i in range(len(df)):
|
| 112 |
+
ch = _as_list_local(df.iloc[i].get("career"))
|
| 113 |
+
durs = [int(c.get("duration_months") or 0) for c in ch]
|
| 114 |
+
durs = [d for d in durs if d > 0]
|
| 115 |
+
avg_ten_arr[i] = sum(durs) / len(durs) if durs else 0.0
|
| 116 |
+
ht = _as_list_local(df.iloc[i].get("career_titles"))
|
| 117 |
+
n_tit_arr[i] = len(ht)
|
| 118 |
+
companies = _as_list_local(df.iloc[i].get("career_companies"))
|
| 119 |
+
is_consulting_only_arr[i] = float(
|
| 120 |
+
all((c or "").strip() in config.IT_SERVICES_PURE_PLAY for c in companies)
|
| 121 |
+
if companies else False
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
is_title_chaser = ((avg_ten_arr < 18.0) & (n_tit_arr >= 4) & (yoe_arr >= 5.0)).astype(np.float32)
|
| 125 |
+
|
| 126 |
+
# Framework enthusiast (refined by super-smart-agent review).
|
| 127 |
+
# Old rule: framework_skills >= 2 AND core_skills < 3.
|
| 128 |
+
# New rule: framework_skills >= 2 AND core_skills < 3 AND
|
| 129 |
+
# (retrieval/eval fundamentals weak: BM25/NDCG/LtR/FAISS
|
| 130 |
+
# count < 2) AND (production evidence < 0.3).
|
| 131 |
+
# This catches candidates with high LangChain/LlamaIndex use but
|
| 132 |
+
# without evidence of building the underlying retrieval/eval systems.
|
| 133 |
+
n_core_arr_val = df["n_core_skills"].astype(float).to_numpy()
|
| 134 |
+
_FW_SET = {"langchain", "llamaindex", "langgraph"}
|
| 135 |
+
_RETRIEVAL_EVAL_KW = {
|
| 136 |
+
"bm25", "faiss", "pinecone", "weaviate", "qdrant", "milvus",
|
| 137 |
+
"elasticsearch", "opensearch", "ndcg", "mrr", "map",
|
| 138 |
+
"learning to rank", "lambdarank", "sentence transformers",
|
| 139 |
+
"bge", "e5",
|
| 140 |
+
}
|
| 141 |
+
fw_score = np.zeros(len(df), dtype=np.float32)
|
| 142 |
+
ret_eval_count = np.zeros(len(df), dtype=np.int32)
|
| 143 |
+
for i in range(len(df)):
|
| 144 |
+
skill_names = _as_list_local(df.iloc[i].get("skill_names"))
|
| 145 |
+
names_lc = {(s or "").lower() for s in skill_names}
|
| 146 |
+
fw_score[i] = sum(1 for nm in names_lc if nm in _FW_SET)
|
| 147 |
+
ret_eval_count[i] = sum(1 for nm in names_lc if nm in _RETRIEVAL_EVAL_KW)
|
| 148 |
+
|
| 149 |
+
# Production evidence
|
| 150 |
+
car_ev_arr = np.zeros(len(df), dtype=np.float32)
|
| 151 |
+
if "career_evidence" in df.columns:
|
| 152 |
+
car_ev_arr = df["career_evidence"].fillna(0.0).astype(float).to_numpy()
|
| 153 |
+
|
| 154 |
+
is_framework_enthusiast = (
|
| 155 |
+
(fw_score >= 2)
|
| 156 |
+
& (n_core_arr_val < 3)
|
| 157 |
+
& (ret_eval_count < 2)
|
| 158 |
+
& (car_ev_arr < 0.3)
|
| 159 |
+
).astype(np.float32)
|
| 160 |
+
|
| 161 |
+
df["is_title_chaser"] = is_title_chaser
|
| 162 |
+
df["is_consulting_only"] = is_consulting_only_arr
|
| 163 |
+
df["is_framework_enthusiast"] = is_framework_enthusiast
|
| 164 |
+
|
| 165 |
+
# 8) Seniority / location / industry features
|
| 166 |
+
df["years_in_ideal_band"] = (
|
| 167 |
+
(df["yoe"] >= config.YOE_IDEAL_LOW) & (df["yoe"] <= config.YOE_IDEAL_HIGH)
|
| 168 |
+
).astype(np.float32)
|
| 169 |
+
df["yoe_log"] = np.log1p(df["yoe"].clip(lower=0))
|
| 170 |
+
|
| 171 |
+
# Seniority: senior/staff/principal/lead vs junior
|
| 172 |
+
_cur_titles = df["current_title"].fillna("").str.lower().tolist()
|
| 173 |
+
_senior_re = re.compile(r"\b(senior|staff|principal|lead|head|chief|architect)\b", re.IGNORECASE)
|
| 174 |
+
_junior_re = re.compile(r"\b(junior|jr\.?|intern|trainee|associate)\b", re.IGNORECASE)
|
| 175 |
+
df["is_senior_title"] = np.array(
|
| 176 |
+
[1.0 if _senior_re.search(t) else 0.0 for t in _cur_titles], dtype=np.float32
|
| 177 |
+
)
|
| 178 |
+
df["is_junior_title"] = np.array(
|
| 179 |
+
[1.0 if _junior_re.search(t) else 0.0 for t in _cur_titles], dtype=np.float32
|
| 180 |
+
)
|
| 181 |
+
df["preferred_location"] = df["location"].fillna("").apply(
|
| 182 |
+
lambda s: int(any(loc.lower() in (s or "").lower() for loc in config.PREFERRED_LOCATIONS))
|
| 183 |
+
)
|
| 184 |
+
df["country_ok"] = df["country"].fillna("").apply(
|
| 185 |
+
lambda s: int(s.strip() in config.COUNTRY_OK)
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
def _as_dict(s):
|
| 189 |
+
if s is None:
|
| 190 |
+
return {}
|
| 191 |
+
if hasattr(s, "tolist"):
|
| 192 |
+
s = s.tolist() if s else {}
|
| 193 |
+
return s if isinstance(s, dict) else {}
|
| 194 |
+
|
| 195 |
+
df["willing_to_relocate"] = df["signals"].apply(
|
| 196 |
+
lambda s: int(bool(_as_dict(s).get("willing_to_relocate", False)))
|
| 197 |
+
)
|
| 198 |
+
df["notice_period_days"] = df["signals"].apply(
|
| 199 |
+
lambda s: int(_as_dict(s).get("notice_period_days", 90))
|
| 200 |
+
)
|
| 201 |
+
df["notice_log"] = np.log1p(df["notice_period_days"].clip(lower=0))
|
| 202 |
+
df["work_mode_remote"] = df["signals"].apply(
|
| 203 |
+
lambda s: int(_as_dict(s).get("preferred_work_mode", "") in ("remote", "flexible", "hybrid"))
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
# 9) Industry / company features
|
| 207 |
+
def _as_list(v):
|
| 208 |
+
if v is None:
|
| 209 |
+
return []
|
| 210 |
+
if hasattr(v, "tolist"):
|
| 211 |
+
return v.tolist()
|
| 212 |
+
return v
|
| 213 |
+
|
| 214 |
+
df["is_services_company"] = df["career_companies"].apply(
|
| 215 |
+
lambda lst: int(any((c or "").strip() in config.IT_SERVICES_PURE_PLAY for c in (_as_list(lst) or [])))
|
| 216 |
+
)
|
| 217 |
+
df["is_product_company"] = df["career_companies"].apply(
|
| 218 |
+
lambda lst: int(any((c or "").strip() in config.PRODUCT_COMPANY_HINTS for c in (_as_list(lst) or [])))
|
| 219 |
+
)
|
| 220 |
+
df["n_industries"] = df["career_industries"].apply(
|
| 221 |
+
lambda lst: len(set(_as_list(lst) or []))
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
return df
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
FEATURE_COLUMNS: List[str] = [
|
| 228 |
+
# retrieval
|
| 229 |
+
"sparse_score", "dense_score", "rrf_score", "structured_hit",
|
| 230 |
+
# graph — raw and log-scaled (the raw PPR values are ~1e-6 noise scale;
|
| 231 |
+
# the LTR benefits enormously from seeing human-scale features)
|
| 232 |
+
"ppr_score",
|
| 233 |
+
"ppr_applied_ml", "ppr_retrieval_rank", "ppr_nlp_llm",
|
| 234 |
+
"ppr_production_eng", "ppr_product_company",
|
| 235 |
+
"ppr_max_axis", "ppr_axis_mean", "ppr_axis_std",
|
| 236 |
+
"ppr_score_log",
|
| 237 |
+
"ppr_applied_ml_log", "ppr_retrieval_rank_log", "ppr_nlp_llm_log",
|
| 238 |
+
"ppr_production_eng_log", "ppr_product_company_log",
|
| 239 |
+
"ppr_max_axis_log", "ppr_axis_mean_log", "ppr_axis_std_log",
|
| 240 |
+
"skill_community_purity", "skill_rarity", "graph_degree",
|
| 241 |
+
"career_coherence", "career_evidence",
|
| 242 |
+
# title
|
| 243 |
+
"title_weight", "title_history_max_weight", "is_target_title",
|
| 244 |
+
"is_noneng_title", "is_data_platform", "is_data_science", "is_generic_swe",
|
| 245 |
+
"n_career_titles", "avg_tenure_months", "title_fit_blend",
|
| 246 |
+
# skills
|
| 247 |
+
"skill_count", "n_core_skills", "n_adj_skills", "n_neg_skills",
|
| 248 |
+
"n_advanced_skills", "n_expert_skills", "must_have_coverage",
|
| 249 |
+
"adjacent_coverage", "ai_signal_strength", "assessment_max",
|
| 250 |
+
"assessment_mean", "endorsement_log_mean", "duration_log_mean",
|
| 251 |
+
"jaccard_core", "jd_criticality_score",
|
| 252 |
+
# behavioral
|
| 253 |
+
"recruit_open_to_work", "recruit_response_rate", "recruit_verified",
|
| 254 |
+
"recruit_completeness", "recruit_recency", "recruit_notice_ok",
|
| 255 |
+
"recruit_recruiter_saves", "recruit_interview_completion",
|
| 256 |
+
"recruit_offer_acceptance", "recruit_github", "recruitability",
|
| 257 |
+
# honeypot
|
| 258 |
+
"honeypot_penalty",
|
| 259 |
+
# seniority / location / industry
|
| 260 |
+
"yoe", "years_in_ideal_band", "yoe_log", "preferred_location",
|
| 261 |
+
"country_ok", "willing_to_relocate", "notice_period_days", "notice_log",
|
| 262 |
+
"work_mode_remote", "is_services_company", "is_product_company",
|
| 263 |
+
"n_industries", "company_tier_current", "company_tier_max",
|
| 264 |
+
"is_top_tier_company", "is_product_company_v2",
|
| 265 |
+
"is_title_chaser", "is_consulting_only", "is_framework_enthusiast",
|
| 266 |
+
# seniority
|
| 267 |
+
"is_senior_title", "is_junior_title",
|
| 268 |
+
]
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
# ---------------------------------------------------------------------------
|
| 272 |
+
# Weak labels (used to train the LambdaRank model)
|
| 273 |
+
# ---------------------------------------------------------------------------
|
| 274 |
+
|
| 275 |
+
def weak_relevance_label(df: pd.DataFrame) -> np.ndarray:
|
| 276 |
+
"""A 0..4 relevance grade derived from a transparent composite.
|
| 277 |
+
|
| 278 |
+
The grade is intentionally not the same as `deterministic_score` — it
|
| 279 |
+
is a *learning target* that emphasises the role blueprint's
|
| 280 |
+
hard requirements (target title, must-have coverage, low honeypot
|
| 281 |
+
penalty) so the ranker can calibrate.
|
| 282 |
+
|
| 283 |
+
Enriched with: multi-axis PPR diversity (breadth), career evidence,
|
| 284 |
+
company tier. Thresholds biased to be more discriminative at the
|
| 285 |
+
top end (tier 4) so the LTR learns to distinguish gold candidates.
|
| 286 |
+
"""
|
| 287 |
+
title_w = df["title_fit_blend"].astype(float).to_numpy()
|
| 288 |
+
must = df["must_have_coverage"].astype(float).to_numpy()
|
| 289 |
+
ppr = df["ppr_score"].astype(float).to_numpy()
|
| 290 |
+
ppr_max = df["ppr_max_axis"].astype(float).to_numpy() if "ppr_max_axis" in df.columns else ppr
|
| 291 |
+
# PPR breadth: standard deviation across the 5 axes
|
| 292 |
+
ppr_axis_cols = [c for c in df.columns
|
| 293 |
+
if c.startswith("ppr_") and c not in ("ppr_score", "ppr_max_axis",
|
| 294 |
+
"ppr_axis_mean", "ppr_axis_std")]
|
| 295 |
+
if ppr_axis_cols:
|
| 296 |
+
ppr_breadth = df[ppr_axis_cols].astype(float).std(axis=1).to_numpy()
|
| 297 |
+
else:
|
| 298 |
+
ppr_breadth = np.zeros(len(df))
|
| 299 |
+
coh = df["career_coherence"].astype(float).to_numpy()
|
| 300 |
+
car_ev = df["career_evidence"].astype(float).to_numpy()
|
| 301 |
+
rec = df["recruitability"].astype(float).to_numpy()
|
| 302 |
+
hp = df["honeypot_penalty"].astype(float).to_numpy()
|
| 303 |
+
jac = df["jaccard_core"].astype(float).to_numpy()
|
| 304 |
+
yoe = df["yoe"].astype(float).to_numpy()
|
| 305 |
+
in_band = df["years_in_ideal_band"].astype(float).to_numpy()
|
| 306 |
+
|
| 307 |
+
# Company tier (only if present)
|
| 308 |
+
if "company_tier_max" in df.columns:
|
| 309 |
+
ctier = df["company_tier_max"].astype(float).to_numpy()
|
| 310 |
+
else:
|
| 311 |
+
ctier = np.zeros(len(df))
|
| 312 |
+
|
| 313 |
+
score = (
|
| 314 |
+
5.0 * title_w
|
| 315 |
+
+ 2.0 * must
|
| 316 |
+
+ 1.5 * np.log1p(ppr_max * 1e5) # multi-axis best axis, log-scaled
|
| 317 |
+
+ 0.8 * ppr_breadth * 1e5 # breadth of fit (axes std)
|
| 318 |
+
+ 1.0 * coh
|
| 319 |
+
+ 1.0 * rec
|
| 320 |
+
- 3.0 * hp
|
| 321 |
+
+ 0.5 * jac
|
| 322 |
+
+ 0.5 * np.log1p(yoe) * in_band
|
| 323 |
+
+ 0.5 * ctier / 3.0 # company tier contribution
|
| 324 |
+
+ 0.3 * df["is_product_company"].astype(float).to_numpy()
|
| 325 |
+
+ 1.5 * df["jd_criticality_score"].astype(float).to_numpy()
|
| 326 |
+
+ 0.7 * car_ev
|
| 327 |
+
# Negative-spec penalties (soft)
|
| 328 |
+
- 0.8 * (df["is_title_chaser"].astype(float).to_numpy()
|
| 329 |
+
if "is_title_chaser" in df.columns
|
| 330 |
+
else np.zeros(len(df)))
|
| 331 |
+
- 0.5 * (df["is_consulting_only"].astype(float).to_numpy()
|
| 332 |
+
if "is_consulting_only" in df.columns
|
| 333 |
+
else np.zeros(len(df)))
|
| 334 |
+
)
|
| 335 |
+
# Convert to 5-point Likert (tuned for better discrimination at top)
|
| 336 |
+
grade = np.zeros(len(df), dtype=np.int32)
|
| 337 |
+
grade[score > 2.0] = 1
|
| 338 |
+
grade[score > 3.5] = 2
|
| 339 |
+
grade[score > 5.0] = 3
|
| 340 |
+
grade[score > 6.5] = 4
|
| 341 |
+
return grade
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
# ---------------------------------------------------------------------------
|
| 345 |
+
# Deterministic fallback scorer (works without training)
|
| 346 |
+
# ---------------------------------------------------------------------------
|
| 347 |
+
|
| 348 |
+
def deterministic_score(df: pd.DataFrame) -> np.ndarray:
|
| 349 |
+
"""JD-tuned deterministic composite.
|
| 350 |
+
|
| 351 |
+
Validated empirically: produces 80 elites in top-100 (vs 55 for the
|
| 352 |
+
v3 LTR-first ranking). Upweights behavior (JD: "active on Redrob
|
| 353 |
+
so we can talk to them"), adds seniority term (Senior/Staff/Lead
|
| 354 |
+
vs Junior), keeps honeypot multiplicative exclusion (the >10% DQ
|
| 355 |
+
filter), keeps JD-criticality, multi-axis PPR, career evidence.
|
| 356 |
+
"""
|
| 357 |
+
s = np.zeros(len(df), dtype=np.float32)
|
| 358 |
+
|
| 359 |
+
# Title (heavy weight — JD emphasises this)
|
| 360 |
+
s += 4.5 * df["title_fit_blend"].astype(float).to_numpy()
|
| 361 |
+
s += 1.5 * df["is_target_title"].astype(float).to_numpy()
|
| 362 |
+
s -= 2.0 * df["is_noneng_title"].astype(float).to_numpy()
|
| 363 |
+
|
| 364 |
+
# Seniority term: senior/staff/principal/lead up, junior down
|
| 365 |
+
if "is_senior_title" in df.columns:
|
| 366 |
+
s += 0.5 * df["is_senior_title"].astype(float).to_numpy()
|
| 367 |
+
s -= 1.0 * df["is_junior_title"].astype(float).to_numpy()
|
| 368 |
+
|
| 369 |
+
# Skills
|
| 370 |
+
s += 2.0 * df["must_have_coverage"].astype(float).to_numpy()
|
| 371 |
+
s += 0.5 * df["adjacent_coverage"].astype(float).to_numpy()
|
| 372 |
+
s += 0.3 * df["jaccard_core"].astype(float).to_numpy()
|
| 373 |
+
s += 0.4 * (df["assessment_max"].fillna(0).astype(float).to_numpy() / 100.0)
|
| 374 |
+
s += 0.4 * (df["assessment_mean"].fillna(0).astype(float).to_numpy() / 100.0)
|
| 375 |
+
# JD-criticality: RAG/FAISS/BM25/evaluation 2× weight
|
| 376 |
+
s += 1.5 * df["jd_criticality_score"].fillna(0).astype(float).to_numpy()
|
| 377 |
+
|
| 378 |
+
# Graph
|
| 379 |
+
s += 1.5 * np.log1p(df["ppr_score"].astype(float).to_numpy() * 1e5)
|
| 380 |
+
# Multi-axis PPR: best axis score + breadth bonus
|
| 381 |
+
if "ppr_max_axis" in df.columns:
|
| 382 |
+
max_ax = df["ppr_max_axis"].astype(float).to_numpy()
|
| 383 |
+
std_ax = df["ppr_axis_std"].astype(float).to_numpy()
|
| 384 |
+
std_max = std_ax.max() + 1e-9
|
| 385 |
+
s += 0.6 * np.log1p(max_ax * 1e5)
|
| 386 |
+
s += 0.3 * (std_ax / std_max) # breadth of fit bonus
|
| 387 |
+
s += 0.8 * df["career_coherence"].astype(float).to_numpy()
|
| 388 |
+
s += 0.8 * df["career_evidence"].astype(float).to_numpy()
|
| 389 |
+
s += 0.5 * df["skill_community_purity"].astype(float).to_numpy()
|
| 390 |
+
s += 0.2 * df["skill_rarity"].astype(float).to_numpy()
|
| 391 |
+
|
| 392 |
+
# Behavior — JD: "active on Redrob so we can actually talk to them"
|
| 393 |
+
s += 2.0 * df["recruitability"].astype(float).to_numpy()
|
| 394 |
+
# Per-feature bonuses for the most important JD signals
|
| 395 |
+
if "recruit_recruiter_saves" in df.columns:
|
| 396 |
+
s += 0.5 * df["recruit_recruiter_saves"].astype(float).to_numpy()
|
| 397 |
+
if "recruit_response_rate" in df.columns:
|
| 398 |
+
s += 0.4 * df["recruit_response_rate"].fillna(0).astype(float).to_numpy()
|
| 399 |
+
|
| 400 |
+
# Honeypot penalty — multiplicative exclusion (the >10% DQ filter)
|
| 401 |
+
hp = df["honeypot_penalty"].astype(float).to_numpy()
|
| 402 |
+
s -= 5.0 * hp
|
| 403 |
+
# Hard push to -inf for honeypot-hard
|
| 404 |
+
s[hp >= config.HONEYPOT_HARD_EXCLUDE] = -1e9
|
| 405 |
+
|
| 406 |
+
# JD "do NOT want" soft penalties
|
| 407 |
+
if "is_title_chaser" in df.columns:
|
| 408 |
+
s -= 0.6 * df["is_title_chaser"].astype(float).to_numpy()
|
| 409 |
+
s -= 0.4 * df["is_framework_enthusiast"].astype(float).to_numpy()
|
| 410 |
+
s -= 0.5 * df["is_consulting_only"].astype(float).to_numpy()
|
| 411 |
+
|
| 412 |
+
# Seniority & location
|
| 413 |
+
s += 0.4 * df["years_in_ideal_band"].astype(float).to_numpy()
|
| 414 |
+
s += 0.3 * df["country_ok"].astype(float).to_numpy()
|
| 415 |
+
s += 0.2 * df["preferred_location"].astype(float).to_numpy()
|
| 416 |
+
s += 0.2 * df["willing_to_relocate"].astype(float).to_numpy()
|
| 417 |
+
s -= 0.4 * (df["notice_period_days"].fillna(90).clip(lower=0).astype(float).to_numpy() / 180.0)
|
| 418 |
+
s += 0.3 * df["is_product_company"].astype(float).to_numpy()
|
| 419 |
+
s -= 0.2 * df["is_services_company"].astype(float).to_numpy() * (1.0 - df["must_have_coverage"].astype(float).to_numpy())
|
| 420 |
+
# Company tier: tier-3 company bonus
|
| 421 |
+
if "company_tier_max" in df.columns:
|
| 422 |
+
s += 0.4 * df["company_tier_max"].astype(float).to_numpy() / 3.0
|
| 423 |
+
s += 0.2 * df["is_top_tier_company"].astype(float).to_numpy()
|
| 424 |
+
|
| 425 |
+
# Retrieval signals (additive)
|
| 426 |
+
if "rrf_score" in df.columns:
|
| 427 |
+
s += 0.6 * df["rrf_score"].fillna(0).astype(float).to_numpy()
|
| 428 |
+
if "sparse_score" in df.columns:
|
| 429 |
+
s += 0.05 * (df["sparse_score"].fillna(0).astype(float).to_numpy() / 30.0)
|
| 430 |
+
if "dense_score" in df.columns:
|
| 431 |
+
s += 0.6 * df["dense_score"].fillna(0).astype(float).to_numpy()
|
| 432 |
+
|
| 433 |
+
# ----------------------------------------------------------------------
|
| 434 |
+
# Conservative availability penalty (added by super-smart-agent review).
|
| 435 |
+
# JD explicitly says "perfect-on-paper but inactive" candidates should
|
| 436 |
+
# be down-weighted. Apply a soft penalty only when MULTIPLE risk signals
|
| 437 |
+
# fire simultaneously — never on a single signal (too noisy).
|
| 438 |
+
# Trigger conditions: low response rate AND stale activity AND long notice.
|
| 439 |
+
# Penalty magnitudes kept small so top-10 is unaffected.
|
| 440 |
+
# ----------------------------------------------------------------------
|
| 441 |
+
# v10: Added Tier 2 (low response + low recruitability) to catch the
|
| 442 |
+
# "strong profile but actively unreachable" archetype that Tier 1 misses
|
| 443 |
+
# when recency/notice happen to be OK on paper.
|
| 444 |
+
try:
|
| 445 |
+
rr = df["recruit_response_rate"].fillna(0.0).astype(float).to_numpy()
|
| 446 |
+
notice = df["notice_period_days"].fillna(90).astype(float).to_numpy()
|
| 447 |
+
recency = df["recruit_recency"].fillna(0.0).astype(float).to_numpy()
|
| 448 |
+
|
| 449 |
+
# Risk flags (0/1)
|
| 450 |
+
low_rr = (rr < 0.15).astype(np.float32) # < 15% response rate
|
| 451 |
+
stale_activity = (recency < 0.20).astype(np.float32) # recency<0.2 means old
|
| 452 |
+
long_notice = (notice > 90).astype(np.float32) # > 90 days notice
|
| 453 |
+
|
| 454 |
+
# Conservative: only penalise when ≥2 risk signals fire
|
| 455 |
+
n_risks = low_rr + stale_activity + long_notice
|
| 456 |
+
# 0 risks → 0, 1 risk → 0, 2 risks → 0.5, 3 risks → 1.0
|
| 457 |
+
risk_severity = np.maximum(n_risks - 1, 0) / 2.0
|
| 458 |
+
# Penalty: at most -1.5 (3 risks); only top-50 affected in practice
|
| 459 |
+
s -= 1.5 * risk_severity
|
| 460 |
+
|
| 461 |
+
# v10 Tier 2: low response + low recruitability composite. This is a
|
| 462 |
+
# SINGLE-signal rule but is the most informative single signal — the
|
| 463 |
+
# recruitability composite already aggregates response+notice+recency,
|
| 464 |
+
# so low recruitability + low response together is a clear "unreachable"
|
| 465 |
+
# signal. Magnitude 0.4 keeps it additive (won't dethrone top-10, which
|
| 466 |
+
# all have response > 0.15 and recruitability > 0.50).
|
| 467 |
+
if "recruitability" in df.columns:
|
| 468 |
+
rec = df["recruitability"].fillna(0.5).astype(float).to_numpy()
|
| 469 |
+
bad_contact = ((rr < 0.15) & (rec < 0.50)).astype(np.float32)
|
| 470 |
+
s -= 0.4 * bad_contact
|
| 471 |
+
except Exception:
|
| 472 |
+
# Be defensive — availability penalty is a soft signal, must not break ranking
|
| 473 |
+
pass
|
| 474 |
+
|
| 475 |
+
# ----------------------------------------------------------------------
|
| 476 |
+
# v10: Non-India + not-willing-to-relocate penalty.
|
| 477 |
+
# JD says India preferred, outside India is case-by-case, no visa
|
| 478 |
+
# sponsorship. Not a hard exclusion (case-by-case), but a moderate penalty
|
| 479 |
+
# so they don't outrank India-based candidates with comparable profiles.
|
| 480 |
+
# Magnitude 0.5 ≈ 2-3 ranks of typical gap; leaves top-10 unchanged
|
| 481 |
+
# (all top-10 are India-based).
|
| 482 |
+
# ----------------------------------------------------------------------
|
| 483 |
+
try:
|
| 484 |
+
country_arr = df["country"].fillna("").astype(str).to_numpy()
|
| 485 |
+
reloc = df["willing_to_relocate"].fillna(0).astype(float).to_numpy()
|
| 486 |
+
non_india_no_reloc = ((country_arr != "India") & (reloc == 0)).astype(np.float32)
|
| 487 |
+
s -= 0.5 * non_india_no_reloc
|
| 488 |
+
except Exception:
|
| 489 |
+
pass
|
| 490 |
+
|
| 491 |
+
return s
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
# ---------------------------------------------------------------------------
|
| 495 |
+
# Train LightGBM ranker
|
| 496 |
+
# ---------------------------------------------------------------------------
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
def _populate_real_retrieval_scores(df: pd.DataFrame) -> pd.DataFrame:
|
| 500 |
+
"""Populate sparse_score / rrf_score / structured_hit / dense_score
|
| 501 |
+
with real (non-zero) values from BM25 + RRF + structured-gate over
|
| 502 |
+
the entire 100k pool.
|
| 503 |
+
|
| 504 |
+
Pre-compute — allowed to exceed the 5min Stage 3 budget because only
|
| 505 |
+
the ranking step in scripts/run_ranking.py must fit the budget. The
|
| 506 |
+
LTR's value is bounded by what features it sees, and 90% importance
|
| 507 |
+
was previously on noise-scale PPR columns because retrieval features
|
| 508 |
+
were zero-filled. This fixes that.
|
| 509 |
+
"""
|
| 510 |
+
import time as _time
|
| 511 |
+
|
| 512 |
+
def _log(msg: str) -> None:
|
| 513 |
+
print(f" [retrieval-prep] {msg}", flush=True)
|
| 514 |
+
|
| 515 |
+
t0 = _time.time()
|
| 516 |
+
df = df.reset_index(drop=True)
|
| 517 |
+
n = len(df)
|
| 518 |
+
|
| 519 |
+
# BM25 over the pool
|
| 520 |
+
from ..retrieval.bm25 import build_bm25, bm25_search
|
| 521 |
+
bm25_obj = build_bm25(force=False)
|
| 522 |
+
sparse_hits = bm25_search(
|
| 523 |
+
config.JD_QUERY_TERMS, top_k=config.BM25_TOP_K, bm25_obj=bm25_obj
|
| 524 |
+
)
|
| 525 |
+
_log(f"BM25 done ({_time.time()-t0:.1f}s)")
|
| 526 |
+
|
| 527 |
+
# Structured-gate as soft score
|
| 528 |
+
gate_terms_lc = {t.lower() for t in config.STRUCTURED_GATE_TERMS}
|
| 529 |
+
|
| 530 |
+
def _gate_text_score(text):
|
| 531 |
+
if not text:
|
| 532 |
+
return 0.0
|
| 533 |
+
tl = text.lower()
|
| 534 |
+
n_hits = sum(1 for g in gate_terms_lc if g in tl)
|
| 535 |
+
return float(min(1.0, n_hits / 5.0))
|
| 536 |
+
|
| 537 |
+
def _gate_skill_score(skills):
|
| 538 |
+
if skills is None:
|
| 539 |
+
return 0.0
|
| 540 |
+
if hasattr(skills, "tolist"):
|
| 541 |
+
skills = skills.tolist()
|
| 542 |
+
n = sum(1 for s in (skills or []) if (s or "").lower() in gate_terms_lc)
|
| 543 |
+
return float(min(1.0, n / 3.0))
|
| 544 |
+
|
| 545 |
+
text_scores = df["text_corpus"].fillna("").apply(_gate_text_score).to_numpy()
|
| 546 |
+
skill_scores = df["skill_names"].apply(_gate_skill_score).to_numpy()
|
| 547 |
+
gate_score = np.maximum(text_scores, skill_scores).astype(np.float32)
|
| 548 |
+
|
| 549 |
+
# Aggregate BM25 max score per candidate (over all queries)
|
| 550 |
+
sparse_max: dict = {}
|
| 551 |
+
for ranking in sparse_hits.values():
|
| 552 |
+
for cid, sc in ranking:
|
| 553 |
+
if sc > sparse_max.get(cid, 0.0):
|
| 554 |
+
sparse_max[cid] = sc
|
| 555 |
+
|
| 556 |
+
# RRF: same logic as run_ranking.py
|
| 557 |
+
from ..retrieval.rrf import rrf_fuse
|
| 558 |
+
rankings = list(sparse_hits.values())
|
| 559 |
+
fused = rrf_fuse(
|
| 560 |
+
rankings, k=config.RRF_K, top_n=n,
|
| 561 |
+
channel_weights=[1.0] * len(rankings),
|
| 562 |
+
)
|
| 563 |
+
rrf_max: dict = {cid: float(s) for cid, s, _ in fused}
|
| 564 |
+
|
| 565 |
+
df["sparse_score"] = df["candidate_id"].map(lambda c: float(sparse_max.get(c, 0.0))).astype(np.float32)
|
| 566 |
+
df["rrf_score"] = df["candidate_id"].map(lambda c: float(rrf_max.get(c, 0.0))).astype(np.float32)
|
| 567 |
+
df["structured_hit"] = gate_score # already a numpy float32 array aligned to df
|
| 568 |
+
# Dense not pre-computed here (would dominate runtime); leave 0 → the LTR
|
| 569 |
+
# will learn that this feature is uninformative when absent at training.
|
| 570 |
+
if "dense_score" not in df.columns:
|
| 571 |
+
df["dense_score"] = 0.0
|
| 572 |
+
_log(f"all retrieval cols populated ({_time.time()-t0:.1f}s)")
|
| 573 |
+
return df
|
| 574 |
+
|
| 575 |
+
|
| 576 |
+
# ---------------------------------------------------------------------------
|
| 577 |
+
|
| 578 |
+
def train_ranker(force: bool = False) -> Tuple[object, List[str]]:
|
| 579 |
+
"""Train a LambdaRank model on the full 100k pool using weak labels.
|
| 580 |
+
|
| 581 |
+
Returns (model, feature_columns).
|
| 582 |
+
"""
|
| 583 |
+
if not force and config.RANKER_TXT.exists():
|
| 584 |
+
import lightgbm as lgb
|
| 585 |
+
return lgb.Booster(model_file=str(config.RANKER_TXT)), FEATURE_COLUMNS
|
| 586 |
+
|
| 587 |
+
df = load_or_build_parquet()
|
| 588 |
+
df = build_feature_frame(df)
|
| 589 |
+
# Pre-compute real retrieval scores for the entire 100k pool so the LTR
|
| 590 |
+
# sees non-zero signals for sparse_score / rrf_score / structured_hit /
|
| 591 |
+
# dense_score. This is pre-compute (allowed to exceed 5min); only the
|
| 592 |
+
# ranking step that produces the CSV must fit the budget.
|
| 593 |
+
df = _populate_real_retrieval_scores(df)
|
| 594 |
+
df["label"] = weak_relevance_label(df)
|
| 595 |
+
|
| 596 |
+
# LambdaRank requires per-group rows <= 10000. We split the 100k pool
|
| 597 |
+
# into ~20 pseudo-queries (groups of 5000 random candidates) so the
|
| 598 |
+
# ranker can learn a global ordering while respecting the limit.
|
| 599 |
+
n = len(df)
|
| 600 |
+
rng = np.random.default_rng(42)
|
| 601 |
+
perm = rng.permutation(n)
|
| 602 |
+
group_size = 5000
|
| 603 |
+
n_groups = (n + group_size - 1) // group_size
|
| 604 |
+
groups: list = []
|
| 605 |
+
for g in range(n_groups):
|
| 606 |
+
start = g * group_size
|
| 607 |
+
end = min(n, start + group_size)
|
| 608 |
+
groups.append(end - start)
|
| 609 |
+
# Reorder dataframe by the permutation so groups are contiguous
|
| 610 |
+
df = df.iloc[perm].reset_index(drop=True)
|
| 611 |
+
y = df["label"].astype(np.int32).to_numpy()
|
| 612 |
+
X = df[FEATURE_COLUMNS].fillna(0).astype(np.float32)
|
| 613 |
+
|
| 614 |
+
import lightgbm as lgb # type: ignore
|
| 615 |
+
|
| 616 |
+
train_data = lgb.Dataset(X, label=y, group=groups)
|
| 617 |
+
model = lgb.train(
|
| 618 |
+
config.RANKER_PARAMS,
|
| 619 |
+
train_data,
|
| 620 |
+
valid_sets=[train_data],
|
| 621 |
+
valid_names=["train"],
|
| 622 |
+
)
|
| 623 |
+
model.save_model(str(config.RANKER_TXT))
|
| 624 |
+
return model, FEATURE_COLUMNS
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
if __name__ == "__main__":
|
| 628 |
+
import sys, time
|
| 629 |
+
t = time.time()
|
| 630 |
+
model, feats = train_ranker(force=True)
|
| 631 |
+
print(f"trained in {time.time()-t:.1f}s, features={len(feats)}")
|
| 632 |
+
df = load_or_build_parquet().head(5)
|
| 633 |
+
df = build_feature_frame(df)
|
| 634 |
+
X = df[feats].fillna(0).astype(np.float32)
|
| 635 |
+
print("scores:", model.predict(X))
|
| 636 |
+
print("det:", deterministic_score(df))
|
redrob/reasoning/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic, evidence-driven reasoning strings for each top-100 candidate.
|
| 2 |
+
|
| 3 |
+
No LLM is used at submission time. The reasoning text is composed from a
|
| 4 |
+
small library of templates that cite specific facts from the candidate's
|
| 5 |
+
profile and connect them to JD requirements.
|
| 6 |
+
"""
|
| 7 |
+
from .template import generate_reasoning
|
| 8 |
+
|
| 9 |
+
__all__ = ["generate_reasoning"]
|
redrob/reasoning/template.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic reasoning template generator — rich, JD-specific, evidence-grounded.
|
| 2 |
+
|
| 3 |
+
Each reasoning is composed of 3-4 short clauses that each answer a SPECIFIC
|
| 4 |
+
question the Stage 4 manual reviewer will ask:
|
| 5 |
+
1) Who is this candidate and what have they shipped? (career evidence)
|
| 6 |
+
2) Which JD requirements does their profile satisfy? (skill→JD mapping)
|
| 7 |
+
3) What's the fit quality — gold/silver/bronze/long-shot?
|
| 8 |
+
4) What concerns should the reviewer verify?
|
| 9 |
+
|
| 10 |
+
The template library uses rank-band × company-tier × evidence-quality cells,
|
| 11 |
+
each containing 3-5 distinct clause templates so the Stage 4 Variation check
|
| 12 |
+
sees substantial diversity (not the same "Senior AI Engineer fit:" 100 times).
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import re
|
| 17 |
+
from typing import Dict, List, Tuple
|
| 18 |
+
|
| 19 |
+
import pandas as pd
|
| 20 |
+
|
| 21 |
+
from .. import config
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
# Company context — one-line industry/product description
|
| 26 |
+
# ---------------------------------------------------------------------------
|
| 27 |
+
# Helps the reviewer understand WHY a candidate at a given company is a fit.
|
| 28 |
+
|
| 29 |
+
_COMPANY_CONTEXT: Dict[str, str] = {
|
| 30 |
+
# Tier-3 global tech
|
| 31 |
+
"Google": "global search/AI platform",
|
| 32 |
+
"Meta": "global social-AI platform",
|
| 33 |
+
"Microsoft": "global enterprise/cloud+AI platform",
|
| 34 |
+
"Amazon": "global e-commerce/cloud platform",
|
| 35 |
+
"Apple": "global consumer hardware/ML platform",
|
| 36 |
+
"Netflix": "global streaming with ML-driven recommendations",
|
| 37 |
+
"Stripe": "global fintech infrastructure",
|
| 38 |
+
"Airbnb": "global marketplace with search/ranking",
|
| 39 |
+
"Uber": "global marketplace with real-time ML",
|
| 40 |
+
"LinkedIn": "professional network with search/ranking",
|
| 41 |
+
"Salesforce": "enterprise SaaS with AI features",
|
| 42 |
+
"Adobe": "creative tools with generative AI",
|
| 43 |
+
"NVIDIA": "GPU/ML infrastructure",
|
| 44 |
+
"Snowflake": "data cloud platform",
|
| 45 |
+
"Databricks": "data/ML platform",
|
| 46 |
+
"OpenAI": "frontier AI lab",
|
| 47 |
+
"Anthropic": "frontier AI lab",
|
| 48 |
+
"Cohere": "enterprise LLM platform",
|
| 49 |
+
"Hugging Face": "open-source ML platform",
|
| 50 |
+
"Pinecone": "vector database company",
|
| 51 |
+
"Weaviate": "open-source vector database",
|
| 52 |
+
"Qdrant": "open-source vector database",
|
| 53 |
+
# Tier-2 Indian product
|
| 54 |
+
"Razorpay": "Indian fintech (payments)",
|
| 55 |
+
"Zerodha": "Indian fintech (brokerage)",
|
| 56 |
+
"CRED": "Indian fintech (credit card rewards)",
|
| 57 |
+
"PhonePe": "Indian fintech (UPI payments)",
|
| 58 |
+
"Paytm": "Indian fintech (payments+banking)",
|
| 59 |
+
"Swiggy": "Indian food delivery marketplace",
|
| 60 |
+
"Zomato": "Indian food delivery marketplace",
|
| 61 |
+
"Flipkart": "Indian e-commerce",
|
| 62 |
+
"Meesho": "Indian social commerce",
|
| 63 |
+
"Urban Company": "Indian services marketplace",
|
| 64 |
+
"Dream11": "Indian fantasy sports platform",
|
| 65 |
+
"Groww": "Indian investing platform",
|
| 66 |
+
"Cars24": "Indian used-car marketplace",
|
| 67 |
+
"Postman": "API development platform",
|
| 68 |
+
"Freshworks": "SaaS/CRM platform",
|
| 69 |
+
"Zoho": "Indian SaaS suite",
|
| 70 |
+
"Chargebee": "Indian subscription billing SaaS",
|
| 71 |
+
"BrowserStack": "Indian testing infrastructure SaaS",
|
| 72 |
+
"Sprinklr": "customer experience platform",
|
| 73 |
+
"CleverTap": "customer engagement platform",
|
| 74 |
+
"MoEngage": "customer engagement platform",
|
| 75 |
+
"ShareChat": "Indian social media",
|
| 76 |
+
"InMobi": "Indian ad-tech",
|
| 77 |
+
"Ola": "Indian ride-hailing",
|
| 78 |
+
"Rivigo": "Indian logistics",
|
| 79 |
+
"Delhivery": "Indian logistics",
|
| 80 |
+
"Udaan": "Indian B2B commerce",
|
| 81 |
+
"Oyo": "Indian hospitality",
|
| 82 |
+
# Indian AI-first labs
|
| 83 |
+
"Sarvam AI": "Indian AI-first lab (foundation models)",
|
| 84 |
+
"Niramai": "Indian health-tech AI",
|
| 85 |
+
"Rephrase": "Indian generative video AI",
|
| 86 |
+
"Fractal": "Indian AI/analytics services",
|
| 87 |
+
"Genpact": "Indian AI services + product",
|
| 88 |
+
"Wipro": "IT services",
|
| 89 |
+
"TCS": "IT services",
|
| 90 |
+
"Infosys": "IT services",
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _company_context(company: str) -> str:
|
| 95 |
+
"""One-line context for a known company, else empty."""
|
| 96 |
+
c = (company or "").strip()
|
| 97 |
+
if not c:
|
| 98 |
+
return ""
|
| 99 |
+
for name, ctx in _COMPANY_CONTEXT.items():
|
| 100 |
+
if name.lower() in c.lower() or c.lower() in name.lower():
|
| 101 |
+
return ctx
|
| 102 |
+
return ""
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ---------------------------------------------------------------------------
|
| 106 |
+
# Skill → JD-requirement mapping
|
| 107 |
+
# ---------------------------------------------------------------------------
|
| 108 |
+
|
| 109 |
+
# Each entry: skill (lowercase) → (why_it_matters, jd_section)
|
| 110 |
+
_JD_SKILL_MEANING: Dict[str, Tuple[str, str]] = {
|
| 111 |
+
"rag": ("RAG is the JD's central retrieval-augmented generation use case", "absolutely need"),
|
| 112 |
+
"retrieval augmented generation": ("RAG is central to the JD's product", "absolutely need"),
|
| 113 |
+
"faiss": ("FAISS is one of the JD's required vector databases", "absolutely need"),
|
| 114 |
+
"pinecone": ("Pinecone is one of the JD's required vector databases", "absolutely need"),
|
| 115 |
+
"weaviate": ("Weaviate is one of the JD's required vector databases", "absolutely need"),
|
| 116 |
+
"qdrant": ("Qdrant is one of the JD's required vector databases", "absolutely need"),
|
| 117 |
+
"milvus": ("Milvus is one of the JD's required vector databases", "absolutely need"),
|
| 118 |
+
"opensearch": ("OpenSearch is one of the JD's required retrieval engines", "absolutely need"),
|
| 119 |
+
"elasticsearch": ("Elasticsearch is one of the JD's required retrieval engines", "absolutely need"),
|
| 120 |
+
"bm25": ("BM25 is the JD's required sparse-retrieval primitive", "absolutely need"),
|
| 121 |
+
"hybrid search": ("Hybrid search is the JD's central retrieval pattern", "absolutely need"),
|
| 122 |
+
"dense retrieval": ("Dense retrieval is central to the JD's hybrid search", "absolutely need"),
|
| 123 |
+
"sentence transformers": ("sentence-transformers experience is core to the JD", "absolutely need"),
|
| 124 |
+
"bge": ("BGE encoder experience aligns with the JD's embedding layer", "absolutely need"),
|
| 125 |
+
"e5": ("E5 encoder experience aligns with the JD's embedding layer", "absolutely need"),
|
| 126 |
+
"embeddings": ("Embedding experience is core to the JD's retrieval", "absolutely need"),
|
| 127 |
+
"learning to rank": ("Learning-to-rank is a JD's required evaluation capability", "absolutely need"),
|
| 128 |
+
"lambdarank": ("LambdaRank directly applies to the JD's ranking pipeline", "absolutely need"),
|
| 129 |
+
"ndcg": ("NDCG is the JD's required ranking evaluation metric", "absolutely need"),
|
| 130 |
+
"mrr": ("MRR is the JD's required evaluation metric", "absolutely need"),
|
| 131 |
+
"map": ("MAP is the JD's required evaluation metric", "absolutely need"),
|
| 132 |
+
"pytorch": ("PyTorch is the JD's expected deep learning framework", "absolutely need"),
|
| 133 |
+
"tensorflow": ("TensorFlow experience is bonus for the JD", "nice to have"),
|
| 134 |
+
"transformers": ("HuggingFace Transformers experience is core to the JD", "absolutely need"),
|
| 135 |
+
"llm": ("LLM experience is core to the JD", "absolutely need"),
|
| 136 |
+
"fine-tuning": ("Fine-tuning experience aligns with the JD's LLM needs", "absolutely need"),
|
| 137 |
+
"lora": ("LoRA experience aligns with the JD's fine-tuning expectations", "absolutely need"),
|
| 138 |
+
"qlora": ("QLoRA experience aligns with the JD's fine-tuning expectations", "absolutely need"),
|
| 139 |
+
"peft": ("PEFT experience aligns with the JD's fine-tuning expectations", "absolutely need"),
|
| 140 |
+
"prompt engineering": ("Prompt engineering is core to the JD's LLM work", "absolutely need"),
|
| 141 |
+
"kubernetes": ("Kubernetes experience aligns with the JD's production deployment", "nice to have"),
|
| 142 |
+
"docker": ("Docker experience aligns with the JD's production deployment", "nice to have"),
|
| 143 |
+
"aws": ("AWS experience is bonus for the JD", "nice to have"),
|
| 144 |
+
"gcp": ("GCP experience is bonus for the JD", "nice to have"),
|
| 145 |
+
"azure": ("Azure experience is bonus for the JD", "nice to have"),
|
| 146 |
+
"mlops": ("MLOps experience aligns with the JD's production needs", "nice to have"),
|
| 147 |
+
"triton": ("Triton experience aligns with the JD's inference infrastructure", "nice to have"),
|
| 148 |
+
"vector database": ("Vector database experience is core to the JD", "absolutely need"),
|
| 149 |
+
"nlp": ("NLP experience is core to the JD", "absolutely need"),
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ---------------------------------------------------------------------------
|
| 154 |
+
# Helpers
|
| 155 |
+
# ---------------------------------------------------------------------------
|
| 156 |
+
|
| 157 |
+
PRODUCT_COMPANIES = set(_COMPANY_CONTEXT.keys()) # aliases for tier detection
|
| 158 |
+
|
| 159 |
+
IT_SERVICES = {
|
| 160 |
+
"TCS", "Infosys", "Wipro", "HCL", "Tech Mahindra", "Cognizant",
|
| 161 |
+
"Capgemini", "Accenture", "Mindtree", "L&T Infotech", "Mphasis",
|
| 162 |
+
"Persistent", "Zensar", "Hexaware", "Birlasoft", "Cyient",
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _fmt_skill(s: str) -> str:
|
| 167 |
+
s = (s or "").strip()
|
| 168 |
+
if not s:
|
| 169 |
+
return ""
|
| 170 |
+
return s
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _canon_skill_local(name: str) -> str:
|
| 174 |
+
"""Local alias canonicalization for the reasoning template (kept in sync
|
| 175 |
+
with redrob.features.skill_features._SKILL_ALIAS)."""
|
| 176 |
+
n = (name or "").strip().lower()
|
| 177 |
+
if not n:
|
| 178 |
+
return ""
|
| 179 |
+
# Map common variants to their canonical form
|
| 180 |
+
aliases = {
|
| 181 |
+
"pgvector": "vector database",
|
| 182 |
+
"vector search": "embeddings",
|
| 183 |
+
"semantic search": "embeddings",
|
| 184 |
+
"fine-tuning llms": "fine-tuning",
|
| 185 |
+
"llm fine-tuning": "fine-tuning",
|
| 186 |
+
"instruction tuning": "fine-tuning",
|
| 187 |
+
"search relevance": "bm25",
|
| 188 |
+
"relevance ranking": "learning to rank",
|
| 189 |
+
"sentence-transformer": "sentence transformers",
|
| 190 |
+
"sbert": "sentence transformers",
|
| 191 |
+
"dense passage retrieval": "dense retrieval",
|
| 192 |
+
"natural language processing": "nlp",
|
| 193 |
+
}
|
| 194 |
+
return aliases.get(n, n)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _top_skills(df_row: pd.Series, k: int = 4) -> List[str]:
|
| 198 |
+
"""Top JD-aligned skills from the candidate's profile.
|
| 199 |
+
|
| 200 |
+
Uses skill-alias canonicalization so that e.g. "pgvector" matches
|
| 201 |
+
the JD's "vector database" requirement.
|
| 202 |
+
"""
|
| 203 |
+
skills_full = df_row.get("skills")
|
| 204 |
+
if skills_full is None:
|
| 205 |
+
skills_full = []
|
| 206 |
+
elif hasattr(skills_full, "tolist"):
|
| 207 |
+
skills_full = skills_full.tolist()
|
| 208 |
+
core_lc = {_canon_skill_local(s) for s in config.CORE_COMPETENCIES}
|
| 209 |
+
adj_lc = {_canon_skill_local(s) for s in config.ADJACENT_COMPETENCIES}
|
| 210 |
+
ranked: List[tuple] = []
|
| 211 |
+
for s in skills_full:
|
| 212 |
+
if not isinstance(s, dict):
|
| 213 |
+
continue
|
| 214 |
+
n = (s.get("name") or "").strip()
|
| 215 |
+
if not n:
|
| 216 |
+
continue
|
| 217 |
+
nl_canon = _canon_skill_local(n)
|
| 218 |
+
if nl_canon in core_lc:
|
| 219 |
+
ranked.append((0, n))
|
| 220 |
+
elif nl_canon in adj_lc:
|
| 221 |
+
ranked.append((1, n))
|
| 222 |
+
else:
|
| 223 |
+
prof = s.get("proficiency", "")
|
| 224 |
+
if prof in ("expert", "advanced"):
|
| 225 |
+
ranked.append((2, n))
|
| 226 |
+
ranked.sort()
|
| 227 |
+
return [n for _, n in ranked[:k]]
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _skill_jd_meaning(skill: str) -> Tuple[str, str]:
|
| 231 |
+
"""Why this skill matters for the JD's 'absolutely need' list."""
|
| 232 |
+
return _JD_SKILL_MEANING.get(skill.lower(), ("", ""))
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _top_skills_with_meaning(df_row: pd.Series, k: int = 4) -> List[Tuple[str, Tuple[str, str]]]:
|
| 236 |
+
"""Top JD-aligned skills paired with their JD-meaning tuple."""
|
| 237 |
+
out: List[Tuple[str, Tuple[str, str]]] = []
|
| 238 |
+
for s in _top_skills(df_row, k=k):
|
| 239 |
+
meaning = _skill_jd_meaning(s)
|
| 240 |
+
if meaning and meaning[0]:
|
| 241 |
+
out.append((s, meaning))
|
| 242 |
+
return out[:k]
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _assessment_str(df_row: pd.Series) -> str:
|
| 246 |
+
sig = df_row.get("signals")
|
| 247 |
+
if sig is None:
|
| 248 |
+
sig = {}
|
| 249 |
+
elif hasattr(sig, "tolist"):
|
| 250 |
+
sig = sig.tolist() if sig else {}
|
| 251 |
+
if not isinstance(sig, dict):
|
| 252 |
+
return ""
|
| 253 |
+
sas = sig.get("skill_assessment_scores") or {}
|
| 254 |
+
vals = [(k, v) for k, v in sas.items()
|
| 255 |
+
if v is not None and isinstance(v, (int, float))]
|
| 256 |
+
if not vals:
|
| 257 |
+
return ""
|
| 258 |
+
top = sorted(vals, key=lambda x: -x[1])[:3]
|
| 259 |
+
return ", ".join(f"{k}={int(v)}" for k, v in top)
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _career_evidence_snippet(df_row: pd.Series) -> str:
|
| 263 |
+
"""Extract one shipped/built/launched verb from career descriptions."""
|
| 264 |
+
career = df_row.get("career")
|
| 265 |
+
if career is None:
|
| 266 |
+
career = []
|
| 267 |
+
elif hasattr(career, "tolist"):
|
| 268 |
+
career = career.tolist()
|
| 269 |
+
if not career:
|
| 270 |
+
return ""
|
| 271 |
+
verb_re = re.compile(
|
| 272 |
+
r"\b(built|shipped|launched|migrated|scaled|deployed|"
|
| 273 |
+
r"productionized|productionised|architected|led|owned|drove|"
|
| 274 |
+
r"designed|implemented|integrated|rolled out)\b",
|
| 275 |
+
re.IGNORECASE,
|
| 276 |
+
)
|
| 277 |
+
target_re = re.compile(
|
| 278 |
+
r"\b(search|ranking|retrieval|recommender|recommendation|ranker|"
|
| 279 |
+
r"vector|embedding|llm|fine.tun|rag|model|system|pipeline|"
|
| 280 |
+
r"platform|infrastructure)\w*\b",
|
| 281 |
+
re.IGNORECASE,
|
| 282 |
+
)
|
| 283 |
+
for ch in career[-3:]:
|
| 284 |
+
desc = (ch.get("description") or "")
|
| 285 |
+
if not desc:
|
| 286 |
+
continue
|
| 287 |
+
# Find first verb-noun pair
|
| 288 |
+
for sent in re.split(r"[.;]\s*", desc):
|
| 289 |
+
v = verb_re.search(sent)
|
| 290 |
+
t = target_re.search(sent)
|
| 291 |
+
if v and t:
|
| 292 |
+
return f"{v.group(0).lower()} {t.group(0).lower()}"
|
| 293 |
+
# Fallback: any verb
|
| 294 |
+
v = verb_re.search(desc)
|
| 295 |
+
if v:
|
| 296 |
+
return f"{v.group(0).lower()} applied-ML systems"
|
| 297 |
+
return ""
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def _yoe_band(yoe: float) -> str:
|
| 301 |
+
if yoe < config.YOE_IDEAL_LOW:
|
| 302 |
+
return (f"{yoe:.1f} yrs "
|
| 303 |
+
f"(slightly under JD's {config.YOE_IDEAL_LOW}-{config.YOE_IDEAL_HIGH} sweet spot)")
|
| 304 |
+
if yoe > config.YOE_IDEAL_HIGH:
|
| 305 |
+
return (f"{yoe:.1f} yrs "
|
| 306 |
+
f"(slightly over JD's {config.YOE_IDEAL_LOW}-{config.YOE_IDEAL_HIGH} sweet spot)")
|
| 307 |
+
return (f"{yoe:.1f} yrs "
|
| 308 |
+
f"(in JD's {config.YOE_IDEAL_LOW}-{config.YOE_IDEAL_HIGH} sweet spot)")
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def _location_str(df_row: pd.Series) -> str:
|
| 312 |
+
loc = (df_row.get("location") or "").strip()
|
| 313 |
+
country = (df_row.get("country") or "").strip()
|
| 314 |
+
s = f"{loc}, {country}" if loc and country else (loc or country or "")
|
| 315 |
+
return s
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def _location_fit(df_row: pd.Series) -> str:
|
| 319 |
+
"""Does the candidate's location match the JD's Pune/Noida preference?"""
|
| 320 |
+
loc = (df_row.get("location") or "").lower()
|
| 321 |
+
if not loc:
|
| 322 |
+
return ""
|
| 323 |
+
preferred = [c.lower() for c in config.PREFERRED_LOCATIONS]
|
| 324 |
+
for p in preferred:
|
| 325 |
+
if p.lower() in loc:
|
| 326 |
+
return "preferred"
|
| 327 |
+
return ""
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def _is_services_company(company: str) -> bool:
|
| 331 |
+
cl = (company or "").lower()
|
| 332 |
+
for s in IT_SERVICES:
|
| 333 |
+
if s.lower() in cl:
|
| 334 |
+
return True
|
| 335 |
+
return False
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def _company_tier(df_row: pd.Series) -> str:
|
| 339 |
+
company = (df_row.get("current_company") or "").strip()
|
| 340 |
+
if not company:
|
| 341 |
+
return "unknown"
|
| 342 |
+
# Tier-3
|
| 343 |
+
for c in PRODUCT_COMPANIES:
|
| 344 |
+
if c.lower() in company.lower():
|
| 345 |
+
return "tier3"
|
| 346 |
+
# Services
|
| 347 |
+
if _is_services_company(company):
|
| 348 |
+
return "services"
|
| 349 |
+
return "tier1"
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _rank_band(rank: int) -> str:
|
| 353 |
+
if rank <= 3:
|
| 354 |
+
return "elite"
|
| 355 |
+
if rank <= 10:
|
| 356 |
+
return "top"
|
| 357 |
+
if rank <= 50:
|
| 358 |
+
return "mid"
|
| 359 |
+
return "tail"
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
# ---------------------------------------------------------------------------
|
| 363 |
+
# Clause builders
|
| 364 |
+
# ---------------------------------------------------------------------------
|
| 365 |
+
|
| 366 |
+
def _clause_identity(df_row: pd.Series, rank: int) -> str:
|
| 367 |
+
"""Clause 1: who they are + career evidence (replaces generic praise)."""
|
| 368 |
+
title = (df_row.get("current_title") or "").strip() or "this candidate"
|
| 369 |
+
company = (df_row.get("current_company") or "").strip()
|
| 370 |
+
company_ctx = _company_context(company)
|
| 371 |
+
yoe = float(df_row.get("yoe") or 0.0)
|
| 372 |
+
band = _rank_band(rank)
|
| 373 |
+
seniority = _detect_seniority(title)
|
| 374 |
+
evidence = _career_evidence_snippet(df_row)
|
| 375 |
+
|
| 376 |
+
# Drop the seniority prefix if the title itself already includes the word
|
| 377 |
+
title_lower = title.lower()
|
| 378 |
+
if seniority and seniority in title_lower:
|
| 379 |
+
prefix = ""
|
| 380 |
+
elif seniority == "principal":
|
| 381 |
+
prefix = "Principal-level "
|
| 382 |
+
elif seniority == "staff":
|
| 383 |
+
prefix = "Staff-level "
|
| 384 |
+
elif seniority == "lead":
|
| 385 |
+
prefix = "Lead-level "
|
| 386 |
+
elif seniority == "senior":
|
| 387 |
+
prefix = "Senior-level "
|
| 388 |
+
else:
|
| 389 |
+
prefix = ""
|
| 390 |
+
|
| 391 |
+
title_phrase = f"{prefix}'{title}'" if prefix else f"'{title}'"
|
| 392 |
+
ctx_phrase = f" ({company_ctx})" if company_ctx else ""
|
| 393 |
+
head = f"{title_phrase} at {company}{ctx_phrase} with {_yoe_band(yoe)}"
|
| 394 |
+
|
| 395 |
+
# Append a career-evidence fragment when available
|
| 396 |
+
if evidence:
|
| 397 |
+
return head + f" — career history shows they have {evidence}."
|
| 398 |
+
|
| 399 |
+
return head + "."
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
def _strip_dup_prefix(title: str, seniority: str) -> str:
|
| 403 |
+
"""If the title already contains the seniority word, don't prefix it."""
|
| 404 |
+
if not seniority:
|
| 405 |
+
return title
|
| 406 |
+
if re.search(rf"\b{seniority}\b", title, re.IGNORECASE):
|
| 407 |
+
return title
|
| 408 |
+
return title
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
def _detect_seniority(title: str) -> str:
|
| 412 |
+
t = title.lower()
|
| 413 |
+
if re.search(r"\bprincipal\b", t): return "principal"
|
| 414 |
+
if re.search(r"\bstaff\b", t): return "staff"
|
| 415 |
+
if re.search(r"\b(head|chief|director|architect)\b", t): return "staff"
|
| 416 |
+
if re.search(r"\b(lead|principal)\b", t): return "lead"
|
| 417 |
+
if re.search(r"\bsenior\b", t): return "senior"
|
| 418 |
+
if re.search(r"\b(junior|jr|intern|trainee)\b", t): return "junior"
|
| 419 |
+
return ""
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def _clause_skill_jd_alignment(df_row: pd.Series) -> str:
|
| 423 |
+
"""Clause 2: connect top skills to specific JD requirements."""
|
| 424 |
+
pairs = _top_skills_with_meaning(df_row, k=3)
|
| 425 |
+
if not pairs:
|
| 426 |
+
# Fallback: just name top skills without meaning
|
| 427 |
+
skills = _top_skills(df_row, k=3)
|
| 428 |
+
if skills:
|
| 429 |
+
return f"Skills intersecting the JD's stack: {', '.join(skills)}."
|
| 430 |
+
return "Limited JD-skill overlap; included for retrieval breadth."
|
| 431 |
+
|
| 432 |
+
parts = []
|
| 433 |
+
for skill, meaning in pairs:
|
| 434 |
+
why = meaning[0] if meaning and meaning[0] else ""
|
| 435 |
+
if why:
|
| 436 |
+
# Use only the first short fragment (before first comma)
|
| 437 |
+
short = why.split(",")[0].strip()
|
| 438 |
+
parts.append(f"{skill} ({short})")
|
| 439 |
+
else:
|
| 440 |
+
parts.append(skill)
|
| 441 |
+
return "Matches JD 'absolutely need' on: " + ", ".join(parts) + "."
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def _clause_assessment(df_row: pd.Series) -> str:
|
| 445 |
+
"""Clause 3: Redrob skill assessment scores (high-value when present)."""
|
| 446 |
+
assess = _assessment_str(df_row)
|
| 447 |
+
if not assess:
|
| 448 |
+
return "No Redrob skill-assessment scores on file (verified skills via profile only)."
|
| 449 |
+
return f"Redrob skill assessments: {assess}."
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def _clause_career_coherence(df_row: pd.Series, rank: int) -> str:
|
| 453 |
+
"""Clause 4 (mid/tail): career-graph / community coherence."""
|
| 454 |
+
car_ev = float(df_row.get("career_evidence") or 0.0)
|
| 455 |
+
car_coh = float(df_row.get("career_coherence") or 0.0)
|
| 456 |
+
if car_ev >= 0.5:
|
| 457 |
+
return "Career-graph evidence: descriptions reference shipped production ranking/retrieval systems."
|
| 458 |
+
if car_coh >= 0.7:
|
| 459 |
+
return "Career-coherence is high: titles form a consistent applied-ML progression."
|
| 460 |
+
return "Included on retrieval + skill signals (career descriptions lack shipped-system keywords)."
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
def _clause_geo_notice(df_row: pd.Series) -> str:
|
| 464 |
+
"""Clause 5: location fit + notice period context."""
|
| 465 |
+
loc = _location_fit(df_row)
|
| 466 |
+
notice = int(df_row.get("notice_period_days") or 0)
|
| 467 |
+
reloc = bool(int(df_row.get("willing_to_relocate") or 0))
|
| 468 |
+
|
| 469 |
+
geo_part = ""
|
| 470 |
+
if loc == "preferred":
|
| 471 |
+
loc_str = _location_str(df_row)
|
| 472 |
+
geo_part = f"Located in JD-preferred city ({loc_str}); relocation not required."
|
| 473 |
+
elif reloc:
|
| 474 |
+
geo_part = f"Willing to relocate to a JD-preferred city (current: {_location_str(df_row)})."
|
| 475 |
+
|
| 476 |
+
notice_part = ""
|
| 477 |
+
if notice and notice <= 30:
|
| 478 |
+
notice_part = f"Notice period {notice} days — within the JD's sub-30-day preference."
|
| 479 |
+
elif notice and notice <= 60:
|
| 480 |
+
notice_part = f"Notice period {notice} days — within tolerance; the JD will buy out up to 30 days."
|
| 481 |
+
elif notice:
|
| 482 |
+
notice_part = f"Notice period {notice} days — above the JD's 30-day preference; bar is higher."
|
| 483 |
+
|
| 484 |
+
if geo_part and notice_part:
|
| 485 |
+
return geo_part + " " + notice_part
|
| 486 |
+
return geo_part or notice_part or ""
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
def _clause_concerns(df_row: pd.Series, rank: int) -> str:
|
| 490 |
+
"""Honest concerns clause."""
|
| 491 |
+
concerns: List[str] = []
|
| 492 |
+
|
| 493 |
+
notice = int(df_row.get("notice_period_days") or 0)
|
| 494 |
+
if notice >= 90:
|
| 495 |
+
concerns.append(f"{notice}-day notice period is above the JD's 30-day preference")
|
| 496 |
+
|
| 497 |
+
honeypot = float(df_row.get("honeypot_penalty") or 0.0)
|
| 498 |
+
if honeypot >= 0.25:
|
| 499 |
+
concerns.append(f"profile shows some inconsistency signals (honeypot penalty {honeypot:.2f})")
|
| 500 |
+
|
| 501 |
+
rec = float(df_row.get("recruitability") or 0.0)
|
| 502 |
+
if rec < 0.3:
|
| 503 |
+
concerns.append("recruiter-engagement signals are muted (verify interest before outreach)")
|
| 504 |
+
|
| 505 |
+
is_junior = bool(int(df_row.get("is_junior_title") or 0))
|
| 506 |
+
yoe = float(df_row.get("yoe") or 0.0)
|
| 507 |
+
in_band = config.YOE_IDEAL_LOW <= yoe <= config.YOE_IDEAL_HIGH
|
| 508 |
+
if is_junior and in_band and rank <= 50:
|
| 509 |
+
concerns.append("junior title despite senior-YOE band — verify level")
|
| 510 |
+
|
| 511 |
+
# Negative-spec flags
|
| 512 |
+
if bool(int(df_row.get("is_consulting_only") or 0)):
|
| 513 |
+
concerns.append("career is consulting-only; JD strongly prefers product-company experience")
|
| 514 |
+
if bool(int(df_row.get("is_framework_enthusiast") or 0)):
|
| 515 |
+
concerns.append("skill mix is framework-heavy (LangChain/LlamaIndex) without deep retrieval fundamentals")
|
| 516 |
+
|
| 517 |
+
if not concerns:
|
| 518 |
+
return "No major concerns."
|
| 519 |
+
if len(concerns) == 1:
|
| 520 |
+
return f"Honest note: {concerns[0]}."
|
| 521 |
+
if len(concerns) == 2:
|
| 522 |
+
return f"Honest notes: {concerns[0]}; {concerns[1]}."
|
| 523 |
+
return "Honest notes: " + "; ".join(concerns[:2]) + f"; and {len(concerns)-2} more."
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
# ---------------------------------------------------------------------------
|
| 527 |
+
# Template assembly per rank-band × company-tier
|
| 528 |
+
# ---------------------------------------------------------------------------
|
| 529 |
+
|
| 530 |
+
def _assemble(df_row: pd.Series, rank: int, band: str, tier: str) -> List[str]:
|
| 531 |
+
"""Return 3-4 clauses appropriate for this rank band and company tier."""
|
| 532 |
+
clauses: List[str] = []
|
| 533 |
+
band_tier = f"{band}_{tier}"
|
| 534 |
+
company = (df_row.get("current_company") or "").strip()
|
| 535 |
+
|
| 536 |
+
# ---- Clause 1: identity + career evidence ----
|
| 537 |
+
c1 = _clause_identity(df_row, rank)
|
| 538 |
+
clauses.append(c1)
|
| 539 |
+
|
| 540 |
+
# ---- Clause 2: skill → JD alignment ----
|
| 541 |
+
clauses.append(_clause_skill_jd_alignment(df_row))
|
| 542 |
+
|
| 543 |
+
# ---- Clause 3: assessment scores (top half only) ----
|
| 544 |
+
if rank <= 50:
|
| 545 |
+
clauses.append(_clause_assessment(df_row))
|
| 546 |
+
|
| 547 |
+
# ---- Clause 4: geo + notice (top half only) ----
|
| 548 |
+
geo_notice = _clause_geo_notice(df_row)
|
| 549 |
+
if geo_notice and rank <= 50:
|
| 550 |
+
clauses.append(geo_notice)
|
| 551 |
+
|
| 552 |
+
# ---- Clause 5: career-graph / coherence (mid/tail) ----
|
| 553 |
+
if band in ("mid", "tail"):
|
| 554 |
+
clauses.append(_clause_career_coherence(df_row, rank))
|
| 555 |
+
|
| 556 |
+
# ---- Clause 6: concerns ----
|
| 557 |
+
if rank <= 100:
|
| 558 |
+
# Always include a concerns clause
|
| 559 |
+
clauses.append(_clause_concerns(df_row, rank))
|
| 560 |
+
|
| 561 |
+
return clauses
|
| 562 |
+
|
| 563 |
+
|
| 564 |
+
def _choose_clauses(rank: int, df_row: pd.Series) -> List[str]:
|
| 565 |
+
"""Choose clauses appropriate for this rank and candidate."""
|
| 566 |
+
band = _rank_band(rank)
|
| 567 |
+
tier = _company_tier(df_row)
|
| 568 |
+
return _assemble(df_row, rank, band, tier)
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
# ---------------------------------------------------------------------------
|
| 572 |
+
# Public entry points
|
| 573 |
+
# ---------------------------------------------------------------------------
|
| 574 |
+
|
| 575 |
+
def generate_reasoning(df_row: pd.Series, rank: int, n_total: int = 100) -> str:
|
| 576 |
+
"""Compose a 3-5 clause reasoning string for one candidate."""
|
| 577 |
+
clauses = _choose_clauses(rank, df_row)
|
| 578 |
+
# Ensure 3-5 clauses
|
| 579 |
+
while len(clauses) < 3:
|
| 580 |
+
clauses.append("No major concerns.")
|
| 581 |
+
clauses = clauses[:5]
|
| 582 |
+
text = " ".join(clauses)
|
| 583 |
+
text = text.replace("\n", " ").replace("\r", " ")
|
| 584 |
+
text = text.replace('"', "'")
|
| 585 |
+
# Trim if too long (validator only requires correct columns, but very long
|
| 586 |
+
# reasoning hurts Stage 4 readability)
|
| 587 |
+
if len(text) > 800:
|
| 588 |
+
text = text[:797] + "..."
|
| 589 |
+
return text
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
def generate_reasoning_dataframe(df: pd.DataFrame) -> List[str]:
|
| 593 |
+
"""Return a list of reasoning strings, one per row, in input order."""
|
| 594 |
+
out: List[str] = []
|
| 595 |
+
n = len(df)
|
| 596 |
+
for i in range(n):
|
| 597 |
+
out.append(generate_reasoning(df.iloc[i], i + 1, n))
|
| 598 |
+
return out
|
redrob/retrieval/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hybrid retrieval layer: BM25 + sentence-transformers + structured gate + RRF."""
|
| 2 |
+
from .bm25 import build_bm25, bm25_search
|
| 3 |
+
from .dense import build_dense_index, dense_search, dense_model_name
|
| 4 |
+
from .rrf import rrf_fuse
|
| 5 |
+
|
| 6 |
+
__all__ = [
|
| 7 |
+
"build_bm25", "bm25_search",
|
| 8 |
+
"build_dense_index", "dense_search", "dense_model_name",
|
| 9 |
+
"rrf_fuse",
|
| 10 |
+
]
|
redrob/retrieval/bm25.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BM25 sparse retrieval over candidate text corpora."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import pickle
|
| 5 |
+
import re
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Dict, List, Optional, Tuple
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
from .. import config
|
| 12 |
+
from ..data import _tokenize, load_or_build_parquet
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _query_tokens(query: str) -> List[str]:
|
| 16 |
+
return _tokenize(query)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def build_bm25(force: bool = False) -> Tuple[object, List[str]]:
|
| 20 |
+
"""Build (or load) the BM25 index over the parquet corpus."""
|
| 21 |
+
if not force and config.BM25_PICKLE.exists() and config.BM25_TOKENS_PICKLE.exists():
|
| 22 |
+
with open(config.BM25_PICKLE, "rb") as f:
|
| 23 |
+
bm25 = pickle.load(f)
|
| 24 |
+
with open(config.BM25_TOKENS_PICKLE, "rb") as f:
|
| 25 |
+
tokens_list = pickle.load(f)
|
| 26 |
+
return bm25, tokens_list
|
| 27 |
+
|
| 28 |
+
from rank_bm25 import BM25Okapi # type: ignore
|
| 29 |
+
|
| 30 |
+
df = load_or_build_parquet()
|
| 31 |
+
tokens_list: List[List[str]] = []
|
| 32 |
+
for txt in df["text_corpus"].tolist():
|
| 33 |
+
tokens_list.append(_tokenize(txt or ""))
|
| 34 |
+
|
| 35 |
+
bm25 = BM25Okapi(tokens_list)
|
| 36 |
+
|
| 37 |
+
with open(config.BM25_PICKLE, "wb") as f:
|
| 38 |
+
pickle.dump(bm25, f, protocol=pickle.HIGHEST_PROTOCOL)
|
| 39 |
+
with open(config.BM25_TOKENS_PICKLE, "wb") as f:
|
| 40 |
+
pickle.dump(tokens_list, f, protocol=pickle.HIGHEST_PROTOCOL)
|
| 41 |
+
return bm25, tokens_list
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def bm25_search(
|
| 45 |
+
queries: List[str],
|
| 46 |
+
top_k: int = config.BM25_TOP_K,
|
| 47 |
+
bm25_obj: Optional[Tuple[object, List[str]]] = None,
|
| 48 |
+
candidate_ids: Optional[List[str]] = None,
|
| 49 |
+
) -> Dict[str, List[Tuple[str, float]]]:
|
| 50 |
+
"""Run BM25 for each query and return top-k per query.
|
| 51 |
+
|
| 52 |
+
Returns a dict mapping query -> list of (candidate_id, score).
|
| 53 |
+
"""
|
| 54 |
+
bm25, _ = bm25_obj or build_bm25()
|
| 55 |
+
df = load_or_build_parquet()
|
| 56 |
+
if candidate_ids is None:
|
| 57 |
+
candidate_ids = df["candidate_id"].tolist()
|
| 58 |
+
n_docs = len(candidate_ids)
|
| 59 |
+
out: Dict[str, List[Tuple[str, float]]] = {}
|
| 60 |
+
for q in queries:
|
| 61 |
+
qtoks = _query_tokens(q)
|
| 62 |
+
if not qtoks:
|
| 63 |
+
out[q] = []
|
| 64 |
+
continue
|
| 65 |
+
scores = bm25.get_scores(qtoks)
|
| 66 |
+
# Top-k by argpartition (O(n)) + small sort
|
| 67 |
+
if top_k >= n_docs:
|
| 68 |
+
order = np.argsort(-scores)
|
| 69 |
+
else:
|
| 70 |
+
idx = np.argpartition(-scores, top_k)[:top_k]
|
| 71 |
+
order = idx[np.argsort(-scores[idx])]
|
| 72 |
+
out[q] = [(candidate_ids[int(i)], float(scores[int(i)])) for i in order]
|
| 73 |
+
return out
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
if __name__ == "__main__":
|
| 77 |
+
bm25, toks = build_bm25(force=False)
|
| 78 |
+
print(f"BM25 docs={len(toks):,}")
|
| 79 |
+
res = bm25_search(["PyTorch transformers LoRA RAG vector search"], top_k=10)
|
| 80 |
+
for q, hits in res.items():
|
| 81 |
+
print(q)
|
| 82 |
+
for cid, sc in hits[:10]:
|
| 83 |
+
print(f" {cid}\t{sc:.3f}")
|
redrob/retrieval/dense.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dense retrieval using sentence-transformers.
|
| 2 |
+
|
| 3 |
+
The model is configurable via env var REDROB_DENSE_MODEL.
|
| 4 |
+
Stronger BGE variants are supported when they are available locally.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Dict, List, Optional, Tuple
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
from .. import config
|
| 16 |
+
from ..data import load_or_build_parquet
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
_DENSE_MODEL_CACHE: Dict[str, object] = {}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def dense_model_name() -> str:
|
| 23 |
+
"""Resolve the dense model name from env, with validation."""
|
| 24 |
+
name = os.environ.get("REDROB_DENSE_MODEL", config.DEFAULT_DENSE_MODEL).strip()
|
| 25 |
+
if name and name not in config.DENSE_MODEL_OPTIONS:
|
| 26 |
+
# Allow any model name the user has cached; warn in logs.
|
| 27 |
+
pass
|
| 28 |
+
return name or config.DEFAULT_DENSE_MODEL
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _load_model():
|
| 32 |
+
name = dense_model_name()
|
| 33 |
+
if name in _DENSE_MODEL_CACHE:
|
| 34 |
+
return _DENSE_MODEL_CACHE[name]
|
| 35 |
+
from sentence_transformers import SentenceTransformer # type: ignore
|
| 36 |
+
# Try the requested model. If it isn't cached and we're offline,
|
| 37 |
+
# fall back to MiniLM (always cached at construction time below).
|
| 38 |
+
candidates = [name] + [m for m in config.DENSE_MODEL_OPTIONS if m != name]
|
| 39 |
+
last_err: Optional[Exception] = None
|
| 40 |
+
for cand in candidates:
|
| 41 |
+
try:
|
| 42 |
+
model = SentenceTransformer(cand, cache_folder=str(config.MODELS_DIR))
|
| 43 |
+
_DENSE_MODEL_CACHE[name] = model
|
| 44 |
+
return model
|
| 45 |
+
except Exception as e: # noqa: BLE001
|
| 46 |
+
last_err = e
|
| 47 |
+
continue
|
| 48 |
+
raise RuntimeError(f"Could not load any sentence-transformers model: {last_err}")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _model_dim() -> int:
|
| 52 |
+
m = _load_model()
|
| 53 |
+
return int(m.get_sentence_embedding_dimension())
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def build_dense_index(force: bool = False, candidate_ids: Optional[List[str]] = None) -> Tuple[np.ndarray, List[str]]:
|
| 57 |
+
"""Encode every candidate's text corpus and return (matrix, ids).
|
| 58 |
+
|
| 59 |
+
If `candidate_ids` is provided, only those candidates are encoded.
|
| 60 |
+
The full 100k corpus is encoded on first call; subsequent runs use
|
| 61 |
+
the cached matrix and align to the candidate_ids subset.
|
| 62 |
+
"""
|
| 63 |
+
if not force and config.DENSE_NPY.exists() and config.DENSE_IDS_JSON.exists():
|
| 64 |
+
emb_all = np.load(config.DENSE_NPY)
|
| 65 |
+
ids_all = json.loads(config.DENSE_IDS_JSON.read_text(encoding="utf-8"))
|
| 66 |
+
if candidate_ids is None:
|
| 67 |
+
return emb_all, ids_all
|
| 68 |
+
# subset to requested ids
|
| 69 |
+
idx_map = {c: i for i, c in enumerate(ids_all)}
|
| 70 |
+
idxs = [idx_map[c] for c in candidate_ids if c in idx_map]
|
| 71 |
+
return emb_all[idxs], [ids_all[i] for i in idxs]
|
| 72 |
+
|
| 73 |
+
model = _load_model()
|
| 74 |
+
df = load_or_build_parquet()
|
| 75 |
+
if candidate_ids is not None:
|
| 76 |
+
df = df[df["candidate_id"].isin(set(candidate_ids))].reset_index(drop=True)
|
| 77 |
+
texts = df["text_corpus"].fillna("").tolist()
|
| 78 |
+
# truncate to model max seq length * 6 chars
|
| 79 |
+
texts = [(t or "")[:2000] for t in texts]
|
| 80 |
+
embeddings = model.encode(
|
| 81 |
+
texts,
|
| 82 |
+
batch_size=256,
|
| 83 |
+
show_progress_bar=False,
|
| 84 |
+
convert_to_numpy=True,
|
| 85 |
+
normalize_embeddings=True,
|
| 86 |
+
).astype(np.float32)
|
| 87 |
+
|
| 88 |
+
ids = df["candidate_id"].tolist()
|
| 89 |
+
np.save(config.DENSE_NPY, embeddings)
|
| 90 |
+
config.DENSE_IDS_JSON.write_text(json.dumps(ids), encoding="utf-8")
|
| 91 |
+
return embeddings, ids
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def dense_search(
|
| 95 |
+
queries: List[str],
|
| 96 |
+
top_k: int = config.DENSE_TOP_K,
|
| 97 |
+
matrix: Optional[Tuple[np.ndarray, List[str]]] = None,
|
| 98 |
+
candidate_ids: Optional[List[str]] = None,
|
| 99 |
+
) -> Dict[str, List[Tuple[str, float]]]:
|
| 100 |
+
"""Run dense retrieval.
|
| 101 |
+
|
| 102 |
+
If a precomputed matrix is passed it is used. Otherwise the
|
| 103 |
+
candidate_ids subset (or full corpus) is encoded and the matrix
|
| 104 |
+
returned for reuse.
|
| 105 |
+
"""
|
| 106 |
+
if matrix is None:
|
| 107 |
+
matrix = build_dense_index(candidate_ids=candidate_ids)
|
| 108 |
+
emb, ids = matrix
|
| 109 |
+
|
| 110 |
+
model = _load_model()
|
| 111 |
+
qvecs = model.encode(
|
| 112 |
+
queries,
|
| 113 |
+
batch_size=64,
|
| 114 |
+
show_progress_bar=False,
|
| 115 |
+
convert_to_numpy=True,
|
| 116 |
+
normalize_embeddings=True,
|
| 117 |
+
).astype(np.float32)
|
| 118 |
+
|
| 119 |
+
out: Dict[str, List[Tuple[str, float]]] = {}
|
| 120 |
+
for q, qv in zip(queries, qvecs):
|
| 121 |
+
scores = emb @ qv # cosine because vectors are normalised
|
| 122 |
+
if top_k >= len(scores):
|
| 123 |
+
order = np.argsort(-scores)
|
| 124 |
+
else:
|
| 125 |
+
idx = np.argpartition(-scores, top_k)[:top_k]
|
| 126 |
+
order = idx[np.argsort(-scores[idx])]
|
| 127 |
+
out[q] = [(ids[int(i)], float(scores[int(i)])) for i in order]
|
| 128 |
+
return out
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
if __name__ == "__main__":
|
| 132 |
+
print("model:", dense_model_name())
|
| 133 |
+
m, ids = build_dense_index(force=False)
|
| 134 |
+
print("emb shape:", m.shape, "ids:", len(ids))
|
| 135 |
+
res = dense_search(["Senior AI Engineer with hybrid search and RAG experience"], top_k=5)
|
| 136 |
+
for q, hits in res.items():
|
| 137 |
+
print(q)
|
| 138 |
+
for cid, sc in hits:
|
| 139 |
+
print(f" {cid}\t{sc:.4f}")
|
redrob/retrieval/rrf.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reciprocal Rank Fusion of multiple ranked lists."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from collections import defaultdict
|
| 5 |
+
from typing import Dict, Iterable, List, Tuple
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def rrf_fuse(
|
| 9 |
+
rankings: List[List[Tuple[str, float]]],
|
| 10 |
+
k: int = 60,
|
| 11 |
+
top_n: int | None = None,
|
| 12 |
+
channel_weights: List[float] | None = None,
|
| 13 |
+
) -> List[Tuple[str, float, Dict[str, int]]]:
|
| 14 |
+
"""Combine multiple ranked lists using weighted RRF.
|
| 15 |
+
|
| 16 |
+
Each ranking is a list of (id, score). Higher score is better.
|
| 17 |
+
channel_weights lets you weight some channels more (e.g. BM25 1.2,
|
| 18 |
+
dense 0.8). Returns a single fused list of (id, rrf_score, per_channel_rank).
|
| 19 |
+
"""
|
| 20 |
+
fused: Dict[str, float] = defaultdict(float)
|
| 21 |
+
ranks: Dict[str, Dict[str, int]] = defaultdict(dict)
|
| 22 |
+
n_channels = len(rankings)
|
| 23 |
+
if channel_weights is None:
|
| 24 |
+
channel_weights = [1.0] * n_channels
|
| 25 |
+
assert len(channel_weights) == n_channels, "channel_weights must match rankings"
|
| 26 |
+
for ch_idx, (ranking, w) in enumerate(zip(rankings, channel_weights)):
|
| 27 |
+
ch_name = f"ch{ch_idx}"
|
| 28 |
+
for r, (cid, _score) in enumerate(ranking, start=1):
|
| 29 |
+
fused[cid] += w / (k + r)
|
| 30 |
+
ranks[cid][ch_name] = r
|
| 31 |
+
items = sorted(fused.items(), key=lambda x: -x[1])
|
| 32 |
+
out: List[Tuple[str, float, Dict[str, int]]] = []
|
| 33 |
+
for cid, s in items:
|
| 34 |
+
out.append((cid, float(s), ranks[cid]))
|
| 35 |
+
if top_n is not None and len(out) >= top_n:
|
| 36 |
+
break
|
| 37 |
+
return out
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
if __name__ == "__main__":
|
| 41 |
+
a = [("A", 1.0), ("B", 0.9), ("C", 0.8)]
|
| 42 |
+
b = [("B", 0.95), ("A", 0.7), ("D", 0.5)]
|
| 43 |
+
fused = rrf_fuse([a, b], k=60)
|
| 44 |
+
for cid, s, r in fused:
|
| 45 |
+
print(cid, s, r)
|
redrob/submit/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Submission writer with validation hook."""
|
| 2 |
+
from .write import write_submission
|
| 3 |
+
|
| 4 |
+
__all__ = ["write_submission"]
|
redrob/submit/write.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Write the final submission CSV and (optionally) run the validator."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import csv
|
| 5 |
+
import subprocess
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import List
|
| 8 |
+
|
| 9 |
+
import pandas as pd
|
| 10 |
+
|
| 11 |
+
from .. import config
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _normalise_score(s: float) -> float:
|
| 15 |
+
"""Squeeze score to [0, 1] in a monotonic way that preserves relative order."""
|
| 16 |
+
if s <= 0:
|
| 17 |
+
return 0.0
|
| 18 |
+
return float(1 / (1 + 2.71828 ** (-0.6 * s)))
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def write_submission(
|
| 22 |
+
df_ranked: pd.DataFrame,
|
| 23 |
+
out_path: Path | str = config.DEFAULT_OUTPUT_CSV,
|
| 24 |
+
validate: bool = True,
|
| 25 |
+
validator: Path | str | None = config.DEFAULT_VALIDATOR,
|
| 26 |
+
) -> Path:
|
| 27 |
+
"""Write the final CSV and (optionally) invoke the validator.
|
| 28 |
+
|
| 29 |
+
Expects `df_ranked` to be sorted best-first, with columns
|
| 30 |
+
`candidate_id` and `final_score`. Reasoning is generated from the
|
| 31 |
+
candidate row.
|
| 32 |
+
|
| 33 |
+
The validator requires that scores be *strictly* non-increasing
|
| 34 |
+
(since the tie-breaker is candidate_id ascending on equal scores).
|
| 35 |
+
We use the raw LightGBM score and apply a 1e-4 epsilon (well below
|
| 36 |
+
the model's scoring resolution but strictly decreasing in float
|
| 37 |
+
comparison). The displayed 6-decimal value preserves the model's
|
| 38 |
+
ranking resolution.
|
| 39 |
+
"""
|
| 40 |
+
from ..reasoning.template import generate_reasoning_dataframe
|
| 41 |
+
|
| 42 |
+
out_path = Path(out_path)
|
| 43 |
+
df_ranked = df_ranked.reset_index(drop=True).copy()
|
| 44 |
+
if len(df_ranked) > 100:
|
| 45 |
+
df_ranked = df_ranked.head(100)
|
| 46 |
+
|
| 47 |
+
# Build a strictly decreasing score for each row.
|
| 48 |
+
# Use the raw final_score (LightGBM LambdaRank produces values in
|
| 49 |
+
# roughly [-15, +15]). Apply a per-index epsilon of 1e-4 which
|
| 50 |
+
# guarantees strict float-decrease while being invisible at 6 decimals.
|
| 51 |
+
norm_scores = df_ranked["final_score"].astype(float).tolist()
|
| 52 |
+
eps = 1e-4
|
| 53 |
+
for i in range(1, len(norm_scores)):
|
| 54 |
+
if norm_scores[i] >= norm_scores[i - 1] - eps * (i - 1):
|
| 55 |
+
norm_scores[i] = norm_scores[i - 1] - eps * i
|
| 56 |
+
|
| 57 |
+
# Reasoning
|
| 58 |
+
reasonings = generate_reasoning_dataframe(df_ranked)
|
| 59 |
+
|
| 60 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 61 |
+
with open(out_path, "w", encoding="utf-8", newline="") as f:
|
| 62 |
+
w = csv.writer(f, quoting=csv.QUOTE_MINIMAL)
|
| 63 |
+
w.writerow(["candidate_id", "rank", "score", "reasoning"])
|
| 64 |
+
for i, row in df_ranked.iterrows():
|
| 65 |
+
cid = str(row["candidate_id"])
|
| 66 |
+
score = f"{norm_scores[i]:.6f}"
|
| 67 |
+
w.writerow([cid, str(i + 1), score, reasonings[i]])
|
| 68 |
+
|
| 69 |
+
if validate and validator and Path(validator).exists():
|
| 70 |
+
try:
|
| 71 |
+
r = subprocess.run(
|
| 72 |
+
["python", str(validator), str(out_path)],
|
| 73 |
+
capture_output=True, text=True, check=False,
|
| 74 |
+
)
|
| 75 |
+
if r.returncode == 0:
|
| 76 |
+
print(f"[validator] OK: {out_path}")
|
| 77 |
+
else:
|
| 78 |
+
print(f"[validator] FAILED:\n{r.stdout}\n{r.stderr}")
|
| 79 |
+
except Exception as e:
|
| 80 |
+
print(f"[validator] error: {e}")
|
| 81 |
+
|
| 82 |
+
return out_path
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
if __name__ == "__main__":
|
| 86 |
+
from ..data import load_or_build_parquet
|
| 87 |
+
from ..rank.predict import rank_shortlist
|
| 88 |
+
df = load_or_build_parquet().sample(120, random_state=0)
|
| 89 |
+
ranked = rank_shortlist(df, use_lgbm=False)
|
| 90 |
+
p = write_submission(ranked, validate=True)
|
| 91 |
+
print("wrote:", p)
|
requirements.txt
CHANGED
|
@@ -1,3 +1,11 @@
|
|
| 1 |
-
|
| 2 |
-
pandas
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.30
|
| 2 |
+
pandas>=2.0
|
| 3 |
+
numpy>=1.24
|
| 4 |
+
scikit-learn>=1.3
|
| 5 |
+
lightgbm>=4.0
|
| 6 |
+
networkx>=3.1
|
| 7 |
+
ijson>=3.2
|
| 8 |
+
rank_bm25>=0.2.2
|
| 9 |
+
pyyaml>=6.0
|
| 10 |
+
tqdm>=4.65
|
| 11 |
+
sentence-transformers>=2.6
|
scripts/run_ranking.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""End-to-end ranking pipeline.
|
| 2 |
+
|
| 3 |
+
This is THE reproduce command listed in submission_metadata.yaml.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
python scripts/run_ranking.py \\
|
| 7 |
+
--candidates "<path-to-candidates.jsonl>" \\
|
| 8 |
+
--out "<path-to-submission.csv>"
|
| 9 |
+
|
| 10 |
+
It streams the candidate pool, builds BM25 + (optional) dense indices,
|
| 11 |
+
fuses them with RRF, computes the full feature set on the shortlist, and
|
| 12 |
+
applies the trained ranker (or the deterministic fallback). The result
|
| 13 |
+
is a 100-row CSV that the validator checks for compliance.
|
| 14 |
+
|
| 15 |
+
If a precomputed artifact is missing, the pipeline rebuilds it. All
|
| 16 |
+
artifacts live under `artifacts/`.
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
import json
|
| 22 |
+
import os
|
| 23 |
+
import pickle
|
| 24 |
+
import re
|
| 25 |
+
import sys
|
| 26 |
+
import time
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
|
| 29 |
+
import numpy as np
|
| 30 |
+
import pandas as pd
|
| 31 |
+
|
| 32 |
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
| 33 |
+
if str(PROJECT_ROOT) not in sys.path:
|
| 34 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 35 |
+
|
| 36 |
+
# Determinism
|
| 37 |
+
os.environ.setdefault("PYTHONHASHSEED", "0")
|
| 38 |
+
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
|
| 39 |
+
|
| 40 |
+
from redrob import config
|
| 41 |
+
from redrob.blueprint import build_blueprint, save_blueprint
|
| 42 |
+
from redrob.data import load_or_build_parquet
|
| 43 |
+
from redrob.retrieval.bm25 import build_bm25, bm25_search
|
| 44 |
+
from redrob.retrieval.dense import (
|
| 45 |
+
build_dense_index, dense_search, dense_model_name,
|
| 46 |
+
)
|
| 47 |
+
from redrob.retrieval.rrf import rrf_fuse
|
| 48 |
+
from redrob.rank.predict import rank_shortlist
|
| 49 |
+
from redrob.submit.write import write_submission
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _save_id_map(path: Path, ids: list) -> None:
|
| 53 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 54 |
+
json.dump(ids, f)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _load_id_map(path: Path) -> list:
|
| 58 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 59 |
+
return json.load(f)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def run(args) -> None:
|
| 63 |
+
t_start = time.time()
|
| 64 |
+
print(f"[{time.time()-t_start:5.1f}s] starting pipeline")
|
| 65 |
+
|
| 66 |
+
# 1) Blueprint
|
| 67 |
+
save_blueprint()
|
| 68 |
+
bp = build_blueprint()
|
| 69 |
+
queries = bp["query_terms"]
|
| 70 |
+
print(f"[{time.time()-t_start:5.1f}s] blueprint saved ({len(config.CORE_COMPETENCIES)} core skills)")
|
| 71 |
+
|
| 72 |
+
# 2) Load or build the canonicalised parquet
|
| 73 |
+
df = load_or_build_parquet(args.candidates)
|
| 74 |
+
print(f"[{time.time()-t_start:5.1f}s] canonicalised: {len(df):,} candidates")
|
| 75 |
+
|
| 76 |
+
# 3) BM25 sparse retrieval
|
| 77 |
+
bm25, _ = build_bm25(force=False)
|
| 78 |
+
sparse_hits = bm25_search(queries, top_k=config.BM25_TOP_K, bm25_obj=(bm25, _))
|
| 79 |
+
sparse_rankings = list(sparse_hits.values())
|
| 80 |
+
print(f"[{time.time()-t_start:5.1f}s] BM25 ready (top-{config.BM25_TOP_K} per query)")
|
| 81 |
+
|
| 82 |
+
# 4) Dense retrieval (or fallback)
|
| 83 |
+
dense_rankings: list = []
|
| 84 |
+
if not args.no_dense:
|
| 85 |
+
try:
|
| 86 |
+
t_dense = time.time()
|
| 87 |
+
print(f"[{time.time()-t_start:5.1f}s] building dense index (model={dense_model_name()}) ...")
|
| 88 |
+
# If the cache exists, use it (fast). Otherwise encode and cache.
|
| 89 |
+
emb, ids = build_dense_index(force=False)
|
| 90 |
+
dense_hits = dense_search(queries, top_k=config.DENSE_TOP_K, matrix=(emb, ids))
|
| 91 |
+
dense_rankings = list(dense_hits.values())
|
| 92 |
+
print(f"[{time.time()-t_start:5.1f}s] dense ready in {time.time()-t_dense:.1f}s")
|
| 93 |
+
except Exception as e: # noqa: BLE001
|
| 94 |
+
print(f"[{time.time()-t_start:5.1f}s] dense unavailable ({e}); using BM25-only")
|
| 95 |
+
dense_rankings = []
|
| 96 |
+
else:
|
| 97 |
+
print(f"[{time.time()-t_start:5.1f}s] dense path disabled by --no_dense")
|
| 98 |
+
|
| 99 |
+
# 5) Structured high-recall gate as SOFT score (saturating)
|
| 100 |
+
gate_terms_lc = {t.lower() for t in config.STRUCTURED_GATE_TERMS}
|
| 101 |
+
df_reset = df.reset_index(drop=True)
|
| 102 |
+
|
| 103 |
+
def _gate_text_score(text):
|
| 104 |
+
if not text:
|
| 105 |
+
return 0
|
| 106 |
+
tl = text.lower()
|
| 107 |
+
n_hits = sum(1 for g in gate_terms_lc if g in tl)
|
| 108 |
+
return float(min(1.0, n_hits / 5.0))
|
| 109 |
+
|
| 110 |
+
def _gate_skill_score(skills):
|
| 111 |
+
if skills is None:
|
| 112 |
+
return 0.0
|
| 113 |
+
if hasattr(skills, "tolist"):
|
| 114 |
+
skills = skills.tolist()
|
| 115 |
+
n = sum(1 for s in (skills or []) if (s or "").lower() in gate_terms_lc)
|
| 116 |
+
return float(min(1.0, n / 3.0))
|
| 117 |
+
|
| 118 |
+
text_scores = df_reset["text_corpus"].fillna("").apply(_gate_text_score).to_numpy()
|
| 119 |
+
skill_scores = df_reset["skill_names"].apply(_gate_skill_score).to_numpy()
|
| 120 |
+
gate_score = np.maximum(text_scores, skill_scores)
|
| 121 |
+
structured_hit = (gate_score > 0.0).astype(np.float32)
|
| 122 |
+
print(f"[{time.time()-t_start:5.1f}s] structured gate: median score {np.median(gate_score):.2f}, "
|
| 123 |
+
f"hits {int(structured_hit.sum()):,}/{len(df):,}")
|
| 124 |
+
|
| 125 |
+
# 6) RRF over all channels with per-channel weights
|
| 126 |
+
# BM25 (reliable) > structured-gate (binary OK) > dense (noisy in this domain)
|
| 127 |
+
rankings = list(sparse_rankings) + list(dense_rankings)
|
| 128 |
+
if dense_rankings:
|
| 129 |
+
channel_weights = [1.2] * len(sparse_rankings) + [0.8] * len(dense_rankings)
|
| 130 |
+
else:
|
| 131 |
+
channel_weights = [1.0] * len(sparse_rankings)
|
| 132 |
+
fused = rrf_fuse(rankings, k=config.RRF_K, top_n=config.SHORTLIST_N,
|
| 133 |
+
channel_weights=channel_weights)
|
| 134 |
+
shortlist_ids = [cid for cid, _score, _r in fused]
|
| 135 |
+
shortlist_set = set(shortlist_ids)
|
| 136 |
+
if args.verbose:
|
| 137 |
+
print(f"[{time.time()-t_start:5.1f}s] RRF shortlist: {len(shortlist_ids):,}")
|
| 138 |
+
|
| 139 |
+
# Add top structured-gate candidates not already in shortlist (sorted by gate_score)
|
| 140 |
+
struct_candidates = df_reset.loc[gate_score > 0.0].copy()
|
| 141 |
+
struct_candidates = struct_candidates.assign(_g=gate_score[gate_score > 0.0])
|
| 142 |
+
struct_candidates = struct_candidates.sort_values("_g", ascending=False)
|
| 143 |
+
added = 0
|
| 144 |
+
for cid in struct_candidates["candidate_id"].tolist():
|
| 145 |
+
if cid not in shortlist_set:
|
| 146 |
+
shortlist_ids.append(cid)
|
| 147 |
+
shortlist_set.add(cid)
|
| 148 |
+
added += 1
|
| 149 |
+
if added >= args.structured_top_up:
|
| 150 |
+
break
|
| 151 |
+
if args.verbose:
|
| 152 |
+
print(f"[{time.time()-t_start:5.1f}s] +{added:,} from structured gate (total shortlist: {len(shortlist_ids):,})")
|
| 153 |
+
struct_ids = df_reset.loc[gate_score > 0.0, "candidate_id"].tolist()
|
| 154 |
+
gate_score_map = {cid: float(s) for cid, s in zip(df_reset["candidate_id"].tolist(), gate_score)}
|
| 155 |
+
|
| 156 |
+
# ----------------------------------------------------------------------
|
| 157 |
+
# Plain-language Tier-5 retrieval top-up (added by super-smart-agent review).
|
| 158 |
+
# Some excellent candidates don't say RAG/Pinecone in skills, but their
|
| 159 |
+
# career DESCRIPTIONS mention shipping ranking/search/recsys systems.
|
| 160 |
+
# Optimised: pre-compile regexes, use only the first N rows for speed,
|
| 161 |
+
# and score on text_corpus (already-built column) instead of building
|
| 162 |
+
# new strings per candidate.
|
| 163 |
+
# ----------------------------------------------------------------------
|
| 164 |
+
_SHIP_KW = re.compile(
|
| 165 |
+
r"\b(built|shipped|launched|scaled|deployed|owned|led|designed|"
|
| 166 |
+
r"implemented|migrated|productionized)\b",
|
| 167 |
+
re.IGNORECASE,
|
| 168 |
+
)
|
| 169 |
+
_RETRIEVAL_KW = re.compile(
|
| 170 |
+
r"\b(matching|personalization|personalisation|marketplace|"
|
| 171 |
+
r"recruiter engagement|click.?through|engagement|"
|
| 172 |
+
r"index|refresh|search system|relevance|re-ranking|reranking|"
|
| 173 |
+
r"semantic|embeddings|indexing|indexed|ranker)\b",
|
| 174 |
+
re.IGNORECASE,
|
| 175 |
+
)
|
| 176 |
+
_PRODUCT_TITLE_KW = re.compile(
|
| 177 |
+
r"\b(ai engineer|machine learning|ml engineer|"
|
| 178 |
+
r"applied scientist|research engineer|search engineer|"
|
| 179 |
+
r"ranking engineer|recommendation|nlp engineer|data scientist)\b",
|
| 180 |
+
re.IGNORECASE,
|
| 181 |
+
)
|
| 182 |
+
_PLAIN_LANG_TOPUP = 200 # how many extra candidates to add
|
| 183 |
+
|
| 184 |
+
plain_lang_candidates = []
|
| 185 |
+
_current_titles = df_reset["current_title"].fillna("").str.lower().tolist()
|
| 186 |
+
_text_corpuses = df_reset["text_corpus"].fillna("").str[:1000].tolist() # cap length
|
| 187 |
+
_cids_all = df_reset["candidate_id"].tolist()
|
| 188 |
+
n_total = len(_cids_all)
|
| 189 |
+
# Scan only candidates not already in shortlist (faster loop)
|
| 190 |
+
scan_indices = [i for i, cid in enumerate(_cids_all) if cid not in shortlist_set]
|
| 191 |
+
|
| 192 |
+
for i in scan_indices:
|
| 193 |
+
# Quick title pre-filter
|
| 194 |
+
if not _PRODUCT_TITLE_KW.search(_current_titles[i] or ""):
|
| 195 |
+
continue
|
| 196 |
+
# Use the pre-built text_corpus (head 1000 chars) — fast substring match
|
| 197 |
+
text = (_text_corpuses[i] or "").lower()
|
| 198 |
+
if not text:
|
| 199 |
+
continue
|
| 200 |
+
n_ship = len(_SHIP_KW.findall(text))
|
| 201 |
+
if n_ship < 1:
|
| 202 |
+
continue
|
| 203 |
+
n_ret = len(_RETRIEVAL_KW.findall(text))
|
| 204 |
+
if n_ret >= 1:
|
| 205 |
+
plain_lang_candidates.append((n_ship + n_ret, _cids_all[i]))
|
| 206 |
+
|
| 207 |
+
plain_lang_candidates.sort(reverse=True)
|
| 208 |
+
added = 0
|
| 209 |
+
for _, cid in plain_lang_candidates[:_PLAIN_LANG_TOPUP]:
|
| 210 |
+
if cid not in shortlist_set:
|
| 211 |
+
shortlist_ids.append(cid)
|
| 212 |
+
shortlist_set.add(cid)
|
| 213 |
+
added += 1
|
| 214 |
+
if args.verbose:
|
| 215 |
+
print(f"[{time.time()-t_start:5.1f}s] +{added:,} from plain-language top-up "
|
| 216 |
+
f"(of {len(plain_lang_candidates):,} eligible; scanned {len(scan_indices):,})")
|
| 217 |
+
|
| 218 |
+
# 7) Build the shortlist dataframe
|
| 219 |
+
shortlist_df = df_reset[df_reset["candidate_id"].isin(shortlist_set)].copy()
|
| 220 |
+
|
| 221 |
+
# 8) Per-channel scores for the shortlist (sparse/dense/rrf)
|
| 222 |
+
sparse_score_by_id: dict = {}
|
| 223 |
+
for ranking in sparse_rankings:
|
| 224 |
+
for cid, sc in ranking:
|
| 225 |
+
if cid in shortlist_set:
|
| 226 |
+
sparse_score_by_id[cid] = max(sparse_score_by_id.get(cid, 0.0), sc)
|
| 227 |
+
dense_score_by_id: dict = {}
|
| 228 |
+
for ranking in dense_rankings:
|
| 229 |
+
for cid, sc in ranking:
|
| 230 |
+
if cid in shortlist_set:
|
| 231 |
+
dense_score_by_id[cid] = max(dense_score_by_id.get(cid, 0.0), sc)
|
| 232 |
+
rrf_score_by_id: dict = {cid: s for cid, s, _r in fused}
|
| 233 |
+
|
| 234 |
+
shortlist_df["sparse_score"] = shortlist_df["candidate_id"].map(lambda c: sparse_score_by_id.get(c, 0.0))
|
| 235 |
+
shortlist_df["dense_score"] = shortlist_df["candidate_id"].map(lambda c: dense_score_by_id.get(c, 0.0))
|
| 236 |
+
shortlist_df["rrf_score"] = shortlist_df["candidate_id"].map(lambda c: rrf_score_by_id.get(c, 0.0))
|
| 237 |
+
shortlist_df["structured_hit"] = shortlist_df["candidate_id"].map(lambda c: gate_score_map.get(c, 0.0))
|
| 238 |
+
|
| 239 |
+
print(f"[{time.time()-t_start:5.1f}s] computing features for {len(shortlist_df):,} shortlist candidates")
|
| 240 |
+
|
| 241 |
+
# 9) Rank (uses LightGBM if model exists, else deterministic fallback)
|
| 242 |
+
use_lgbm = not args.no_lgbm
|
| 243 |
+
if not use_lgbm:
|
| 244 |
+
print(f"[{time.time()-t_start:5.1f}s] ranker: deterministic fallback (--no_lgbm)")
|
| 245 |
+
else:
|
| 246 |
+
print(f"[{time.time()-t_start:5.1f}s] ranker: LightGBM (auto-trains if missing)")
|
| 247 |
+
ranked = rank_shortlist(shortlist_df, use_lgbm=use_lgbm)
|
| 248 |
+
|
| 249 |
+
# ----------------------------------------------------------------------
|
| 250 |
+
# v11: Final logistics eligibility filter.
|
| 251 |
+
# JD: "Location: Pune/Noida-preferred but flexible. Outside India:
|
| 252 |
+
# case-by-case, but we don't sponsor work visas."
|
| 253 |
+
# A non-India candidate who is not willing to relocate is, for this
|
| 254 |
+
# company, functionally unreachable. Remove them from the ranked
|
| 255 |
+
# shortlist BEFORE selecting top-100, so they are replaced by the
|
| 256 |
+
# next eligible candidate from below. This does NOT change top-10
|
| 257 |
+
# or top-50 (all six removed are in ranks 51–100) and preserves any
|
| 258 |
+
# non-India candidate who IS willing to relocate.
|
| 259 |
+
# ----------------------------------------------------------------------
|
| 260 |
+
country_arr = ranked["country"].fillna("").astype(str).to_numpy()
|
| 261 |
+
reloc_arr = ranked["willing_to_relocate"].fillna(0).astype(float).to_numpy()
|
| 262 |
+
eligible_mask = ~((country_arr != "India") & (reloc_arr == 0))
|
| 263 |
+
n_removed = int((~eligible_mask).sum())
|
| 264 |
+
print(f"[{time.time()-t_start:5.1f}s] v11 eligibility filter: kept "
|
| 265 |
+
f"{int(eligible_mask.sum()):,}, removed {n_removed} non-India + not-willing-to-relocate")
|
| 266 |
+
|
| 267 |
+
# 10) Top-100 (selected from the eligible subset)
|
| 268 |
+
top = ranked[eligible_mask].head(100).copy()
|
| 269 |
+
print(f"[{time.time()-t_start:5.1f}s] top-100 selected")
|
| 270 |
+
|
| 271 |
+
# 11) Honeypot self-check
|
| 272 |
+
n_honeypot = int((top["honeypot_penalty"] >= config.HONEYPOT_HARD_EXCLUDE).sum())
|
| 273 |
+
n_low_title = int((top["title_fit_blend"] < 0.10).sum())
|
| 274 |
+
n_services_top50 = int(top.head(50)["is_services_company"].astype(int).sum()) if "is_services_company" in top.columns else 0
|
| 275 |
+
print(f"[{time.time()-t_start:5.1f}s] self-check: honeypot_hard={n_honeypot}, low_title_top100={n_low_title}, services_top50={n_services_top50}")
|
| 276 |
+
|
| 277 |
+
# 12) Write CSV
|
| 278 |
+
out = write_submission(top, out_path=args.out, validate=True)
|
| 279 |
+
print(f"[{time.time()-t_start:5.1f}s] submission written: {out}")
|
| 280 |
+
print(f"[{time.time()-t_start:5.1f}s] DONE")
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def main() -> None:
|
| 284 |
+
ap = argparse.ArgumentParser()
|
| 285 |
+
ap.add_argument(
|
| 286 |
+
"--candidates",
|
| 287 |
+
default=str(config.DEFAULT_CANDIDATES_JSONL),
|
| 288 |
+
help="Path to candidates.jsonl",
|
| 289 |
+
)
|
| 290 |
+
ap.add_argument(
|
| 291 |
+
"--out",
|
| 292 |
+
default=str(config.DEFAULT_OUTPUT_CSV),
|
| 293 |
+
help="Path to submission CSV",
|
| 294 |
+
)
|
| 295 |
+
ap.add_argument(
|
| 296 |
+
"--no_dense",
|
| 297 |
+
action="store_true",
|
| 298 |
+
help="Disable sentence-transformers (use BM25-only retrieval)",
|
| 299 |
+
)
|
| 300 |
+
ap.add_argument(
|
| 301 |
+
"--no_lgbm",
|
| 302 |
+
action="store_true",
|
| 303 |
+
help="Use the deterministic scorer instead of the LightGBM ranker",
|
| 304 |
+
)
|
| 305 |
+
ap.add_argument(
|
| 306 |
+
"--structured_top_up",
|
| 307 |
+
type=int,
|
| 308 |
+
default=2000,
|
| 309 |
+
help="How many structured-gate candidates to add beyond the RRF shortlist",
|
| 310 |
+
)
|
| 311 |
+
ap.add_argument("--verbose", action="store_true")
|
| 312 |
+
args = ap.parse_args()
|
| 313 |
+
run(args)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
if __name__ == "__main__":
|
| 317 |
+
main()
|