RedrobJobRanker / redrob /graph /propagate.py
optimus
Deploy Redrob Streamlit sandbox
1aebf33 verified
Raw History Blame Contribute Delete
4.16 kB
"""Personalised PageRank over the heterogeneous graph.
Supports both the original single-axis PPR and a multi-axis PPR
(5 JD axes: applied_ml, retrieval_rank, nlp_llm, production_eng, product_company).
Multi-axis results are cached on disk at artifacts/ppr_axes.pkl.
"""
from __future__ import annotations
import pickle
from pathlib import Path
from typing import Dict, Iterable, List
import networkx as nx
from .. import config
from .build import build_graph
def _seed_nodes(jd_terms: Iterable[str], prefix: str) -> List[str]:
"""Map JD terms to graph node labels with the given prefix."""
out: List[str] = []
for term in jd_terms:
key = f"{prefix}::{term.strip()}"
out.append(key)
return out
def personalised_pagerank(
jd_terms: Iterable[str] | None = None,
alpha: float = 0.85,
max_iter: int = 60,
) -> Dict[str, float]:
"""Return a dict of {candidate_node: ppr_score}."""
G = build_graph()
seeds = _seed_nodes(jd_terms or config.CORE_COMPETENCIES, "skill")
seeds = [s for s in seeds if s in G]
if not seeds:
return {}
personalization = {n: 0.0 for n in G.nodes()}
for s in seeds:
personalization[s] = 1.0 / len(seeds)
pr = nx.pagerank(
G,
alpha=alpha,
personalization=personalization,
max_iter=max_iter,
tol=1e-6,
weight="weight",
)
return {n: float(v) for n, v in pr.items()
if isinstance(n, str) and n.startswith("cand::")}
# Disk cache path for multi-axis PPR
_PPR_AXES_CACHE = config.ARTIFACTS_DIR / "ppr_axes.pkl"
def ppr_axes(
axes: Dict[str, List[str]] | None = None,
alpha: float = 0.85,
max_iter: int = 60,
) -> Dict[str, Dict[str, float]]:
"""Multi-axis PPR: one PPR per JD axis.
Returns Dict[axis_name, Dict[cand_node, ppr_score]].
Results are cached at artifacts/ppr_axes.pkl.
Axes:
applied_ml -> seeds from config.JD_AXES["applied_ml"] on skill:: nodes
retrieval_rank -> seeds from config.JD_AXES["retrieval_rank"] on skill:: nodes
nlp_llm -> seeds from config.JD_AXES["nlp_llm"] on skill:: nodes
production_eng -> seeds from config.JD_AXES["production_eng"] on skill:: nodes
product_company -> seeds from config.JD_AXES["product_company"] on company:: nodes
"""
if axes is None:
axes = config.JD_AXES
# Load cache if present
if _PPR_AXES_CACHE.exists():
with open(_PPR_AXES_CACHE, "rb") as f:
cached: Dict[str, Dict[str, float]] = pickle.load(f)
# Validate all axes are present
if all(ax in cached for ax in axes):
return cached
G = build_graph()
result: Dict[str, Dict[str, float]] = {}
for axis_name, terms in axes.items():
# product_company axis uses company:: nodes; all others use skill::
prefix = "company" if axis_name == "product_company" else "skill"
seeds = _seed_nodes(terms, prefix)
seeds = [s for s in seeds if s in G]
if not seeds:
result[axis_name] = {}
print(f" ppr_axes[{axis_name}]: 0 seeds found in graph")
continue
personalization = {n: 0.0 for n in G.nodes()}
for s in seeds:
personalization[s] = 1.0 / len(seeds)
pr = nx.pagerank(
G,
alpha=alpha,
personalization=personalization,
max_iter=max_iter,
tol=1e-6,
weight="weight",
)
result[axis_name] = {
n: float(v)
for n, v in pr.items()
if isinstance(n, str) and n.startswith("cand::")
}
top_score = max(result[axis_name].values()) if result[axis_name] else 0.0
print(f" ppr_axes[{axis_name}]: {len(seeds)} seeds, top_score={top_score:.6f}")
# Persist to disk
with open(_PPR_AXES_CACHE, "wb") as f:
pickle.dump(result, f, protocol=pickle.HIGHEST_PROTOCOL)
return result
if __name__ == "__main__":
axes = ppr_axes()
for name, scores in axes.items():
top = sorted(scores.items(), key=lambda x: -x[1])[:3]
print(f"{name}: {top}")