Potencia tu CV con Python (II): Usando NLP para hacer match con ofertas#

¡Hola de nuevo! En este artículo anterior les conté cómo desarrollé un sistema para estructurar y analizar mi CV utilizando inteligencia artificial. Hoy quiero compartir la segunda parte de este proyecto: el análisis de compatibilidad entre mi CV y las ofertas laborales.

Python developer

Después de lograr estructurar mi CV de manera eficiente, me di cuenta de que el siguiente paso lógico era determinar qué tan compatible soy con las ofertas de trabajo que encuentro. ¿De qué sirve tener un CV bien estructurado si no sabemos a cuáles ofertas vale la pena aplicar?

Procesamiento bilingüe#

La primera característica en la que pensé es que pueda trabajar tanto con ofertas en español como en inglés. Esto es crucial para quienes buscamos trabajo en mercados internacionales:

class JobMatcher:
    def __init__(self):
        # Cargar el modelo de spaCy para ingles
        # Si necesitas otro idioma, cambia 'en_core_web_sm' por el modelo correspondiente
        # Por ejemplo, 'es_core_news_sm' para español
        try:
            self.nlp_es = spacy.load("es_core_news_md")
            self.nlp_en = spacy.load("en_core_web_md")
        except OSError:
            # Si los modelos no están instalados, descárgalos
            print("Descargando modelos de spaCy...")
            os.system("python -m spacy download es_core_news_md")
            os.system("python -m spacy download en_core_web_md")
            self.nlp_es = spacy.load("es_core_news_md")
            self.nlp_en = spacy.load("en_core_web_md")

El sistema detecta automáticamente el idioma del texto analizando palabras comunes:

    def get_language_nlp(self, text: str) -> str:
        es_words = ["el", "la", "los", "las", "y", "en", "de", "para", "con", "por"]
        en_words = ["the", "and", "in", "of", "to", "for", "with", "by", "on", "at"]

        text_lower = text.lower()
        es_count = sum(1 for word in es_words if f" {word} " in f" {text_lower} ")
        en_count = sum(1 for word in en_words if f" {word} " in f" {text_lower} ")

        return "es" if es_count > en_count else "en"

    def get_doc(self, text: str) -> Doc:
        text_language = self.get_language_nlp(text)
        if text_language == "es":
            return self.nlp_es(text)
        return self.nlp_en(text)

El algoritmo de compatibilidad#

Ahora comenzamos con el core de la lógica, vamos a definir la compatibilidad usando el título de la oferta (Ej. Senior Python Developer) y también la lista de skills definidos (Python, Flask, SQL) asi que los extraemos y los calculamos por separado:

def analyze_job_offer(self, cv_data: dict, job_data: dict) -> dict:
    position_score = self.analyze_positions(cv_data, job_data)
    skills_score = self.analyze_skills(cv_data, job_data)
    
    overall_score = position_score * 0.5 + skills_score * 0.5

    return {
        "overall_score": overall_score,
        "position_score": position_score,
        "skills_score": skills_score,
    }

Ahora veremos la implementación de ambos scores individualmente:

1. Compatibilidad de posición#

comparemos el título del puesto ofrecido con mis experiencias laborales previas, buscando la mejor coincidencia:

def analyze_positions(self, cv_data: dict, job_data: dict) -> float:
    scores = []
    experience = cv_data.get("experience", [])

    for exp in experience:
        position_score = self.get_position_score(
            job_data.get("job_title", ""), exp.get("position", "")
        )
        scores.append(position_score)
    
    max_score = max(scores) if scores else 0.0
    return max_score

Lo interesante aquí es que no solo busca coincidencias exactas, sino que utiliza la similitud semántica para entender si mi experiencia es relevante para el puesto. Además, considera el nivel de seniority:

    def get_position_score(self, job_post_title: str, exp_position: str) -> float:
        if not job_post_title or not exp_position:
            return 0.0
        job_doc = self.get_doc(job_post_title)
        exp_doc = self.get_doc(exp_position)

        # Calcular la similitud vectorial entre el título de trabajo y la posición del empleado
        similarity = float(exp_doc.similarity(job_doc))

        seniority_levels = {
            "intern": 1,
            "junior": 2,
            "associate": 2,
            "i": 2,
            "ii": 3,
            "mid": 3,
            "intermediate": 3,
            "iii": 4,
            "senior": 5,
            "sr": 5,
            "lead": 6,
            "principal": 7,
            "staff": 7,
            "architect": 8,
            "director": 9,
            "head": 9,
            "chief": 10,
        }

        # extraer el niver de seniority
        job_seniority = 0
        exp_seniority = 0

        for token in job_doc:
            if token.text in seniority_levels:
                job_seniority = seniority_levels[token.text]
                break

        for token in exp_doc:
            if token.text in seniority_levels:
                exp_seniority = seniority_levels[token.text]
                break

        # Calcular la diferencia de niveles de senioridad y aplicar un penalizador
        seniority_diff = abs(job_seniority - exp_seniority)
        seniority_penalty = min(seniority_diff * 0.1, 0.5)  # Cap penalizador a 0.5

        similarity *= 1 - seniority_penalty

        return similarity

2. Compatibilidad de habilidades#

Esta parte es crucial: ¿tengo las habilidades técnicas que la empresa está buscando?

    def analyze_skills(self, cv_data: dict, job_data: dict) -> float:
        # Obtengo las habilidades del CV y las de cada emperiencia
        skills = set(cv_data.get("skills", []))
        for exp in cv_data.get("experience", []):
            skills.update(exp.get("skills", []))
        job_skills = set(job_data.get("skills", {}).get("technologies", []))

        # Calcular el match directo
        direct_matches = skills.intersection(job_skills)
        direct_matches_score = (
            len(direct_matches) / len(job_skills) if job_skills else 0.0
        )

Pero aquí viene lo realmente interesante. ¿Qué pasa con las habilidades que no coinciden exactamente pero están relacionadas? Por ejemplo, si la oferta pide “React.js” y yo tengo “React” en mi CV, o si piden “AWS” y yo tengo “Amazon Web Services”. Para resolver esto, implementé un análisis de similitud semántica:


        # Podemos tambien calcular los matches semanticos que no coinciden directamente
        remaining_job_skills = skills - direct_matches
        if not remaining_job_skills:
            return 1.0

        semantic_match_score = 0.0
        remaining_cv_skills = skills - direct_matches
        semantic_matches = 0

        # Definir un umbral de similitud para los matches semanticos
        skill_similarity_threshold = 0.75

        for job_skill in remaining_job_skills:
            job_doc = self.get_doc(job_skill)
            best_similarity = 0.0

            for cv_skill in remaining_cv_skills:
                cv_doc = self.get_doc(cv_skill)
                similarity = job_doc.similarity(cv_doc)

                if similarity > best_similarity:
                    best_similarity = similarity

            if best_similarity >= skill_similarity_threshold:
                semantic_matches += 1
        semantic_match_score = (
            semantic_matches / len(remaining_job_skills)
            if remaining_job_skills
            else 0.0
        )

        final_score = 0.8 * direct_matches_score + 0.2 * semantic_match_score
        return final_score

Y gracias a esto puedo generar un score más preciso, por ahora tengo un resultado decente, en algún momento pensé en implementar OpenAI para un análisis mas semantico pero eso puede dar mas espacio para otro artículo.

Puedes revisar el código en este proyecto: 02_job_match_compatibility.py

También implementé un script para estructurar una oferta laboral usando OpenAI 02_extract_job_information.py