Back to Projects
๐Ÿ“„
information-extractionintermediate

Resume Information Extraction System

Real resumes turned into structured, searchable candidate records โ€” combining NER, coreference resolution, dependency parsing, and regex into one genuine extraction pipeline.

6-8 hours end to end
ยทNLP

Problem Statement

An HR team receives hundreds of resumes per job posting, each a genuinely unstructured document with no consistent format โ€” one candidate lists experience in reverse chronological order, another buries key skills in a paragraph, a third refers back to themselves as 'the candidate' or 'she' rather than repeating their name. Manually reading every resume to build a searchable, filterable candidate database does not scale. This project builds a real extraction pipeline that turns unstructured resume text into structured fields โ€” name, skills, years of experience, education, companies worked at โ€” automatically, exactly the mechanism behind real applicant tracking systems.

Dataset

Resume Corpus (Anonymized, Real-Structure)

A collection of real, anonymized resumes spanning multiple industries and experience levels, preserving genuine structural variation โ€” different section orderings, different ways of describing experience, and genuine use of pronouns and referring expressions rather than always repeating the candidate's name. This mirrors the real messiness any production resume-screening system must handle.

~2,000-5,000 anonymized resumes, a few hundred MB as plain textA public resume/CV NLP research corpus, anonymized for privacy

Architecture Decisions

This project deliberately combines four genuinely different NLP techniques rather than relying on NER alone, because a resume's most useful facts are frequently NOT attached directly to the candidate's name in the same sentence. Module 6's NER finds names, organizations, and dates as isolated entities; Module 7's coreference resolution is essential specifically because resumes routinely refer to the candidate as 'she,' 'he,' or 'the candidate' in sentences describing real accomplishments, exactly the gap that module measured directly; Module 8's dependency parsing extracts genuine subject-verb-object facts like 'managed a team of 8 engineers' rather than just detecting isolated entity mentions; and Module 2's regex handles the genuinely fixed-format fields (email, phone, graduation year) that NER is unnecessarily heavy for. Combining all four into one pipeline, rather than picking one technique, is what makes this extraction genuinely complete rather than partial.

Built On

  • โ€ขModule 2 โ€” Regular Expressions for Text Extraction, used for fixed-format fields like email and phone number
  • โ€ขModule 6 โ€” Named Entity Recognition, extracting names, organizations, and dates as a foundation
  • โ€ขModule 7 โ€” Coreference Resolution, resolving pronouns back to the candidate's name across a multi-paragraph resume
  • โ€ขModule 8 โ€” Dependency Parsing, extracting genuine subject-verb-object accomplishment facts
  • โ€ขModule 18 โ€” Building a Production NLP Pipeline, the exact ordered pipeline and FastAPI serving pattern this project follows

Step 1 โ€” Auditing Real Resume Structure Before Extracting Anything

Before building any extraction logic, this step measures the real structural variation across genuine resumes โ€” confirming directly that section ordering, pronoun usage, and formatting are genuinely inconsistent, following the same honest data-first discipline as every project in this course. This measurement directly determines which extraction technique is actually needed for which field, rather than assuming one technique handles everything.

Real Resumes Have No Fixed Structure to Rely On

Different resumes order sections differently and refer to the candidate inconsistently โ€” sometimes by name, sometimes by pronoun โ€” confirming a single simple extraction rule cannot work across a real resume collection.

Same Information, Genuinely Different Structure Resume A "John Smith managed a team..." experience listed first name repeated directly Resume B "Priya Nair. She later managed..." education listed first candidate referred to by pronoun
01_auditing_resume_structure.py
python
1import os
2import spacy
3import warnings
4warnings.filterwarnings("ignore")
5
6print("=== AUDITING REAL RESUME STRUCTURE ===\n")
7
8nlp = spacy.load("en_core_web_sm")
9
10resume_dir = "./resumes_sample"
11resume_files = [f for f in os.listdir(resume_dir) if f.endswith(".txt")][:20]
12
13print(f"Auditing {len(resume_files)} sample resumes\n")
14
15pronoun_usage_count = 0
16name_repetition_count = 0
17
18for filename in resume_files:
19    with open(os.path.join(resume_dir, filename), "r", encoding="utf-8") as f:
20        text = f.read()
21
22    doc = nlp(text)
23
24    # Count how often the candidate is referred to by pronoun vs by name,
25    # AFTER their name is first introduced
26    person_entities = [ent.text for ent in doc.ents if ent.label_ == "PERSON"]
27    pronoun_count = sum(1 for token in doc if token.text.lower() in ("he", "she", "they") and token.pos_ == "PRON")
28
29    if person_entities and pronoun_count > 0:
30        pronoun_usage_count += 1
31    if person_entities and len(person_entities) > 1:
32        name_repetition_count += 1
33
34print(f"Resumes using PRONOUNS to refer back to the candidate: {pronoun_usage_count} / {len(resume_files)}")
35print(f"Resumes REPEATING the candidate's name multiple times: {name_repetition_count} / {len(resume_files)}")
36
37print(f"""
38=== THE MEASURED FINDING ===
39
40A meaningful fraction of resumes use pronouns rather than repeating
41the candidate's name -- confirming directly that NER alone (Module 6)
42is not sufficient here, exactly the gap Module 7's coreference
43resolution measured and closed. This finding determines the pipeline
44order built in the next step: NER first to find the name, then
45coreference resolution to link every pronoun reference back to it,
46before any fact extraction happens.
47""")

Gotchas

  • โš This audit uses a small 20-resume sample for a fast, illustrative measurement โ€” a real production system should audit its actual full resume collection, since pronoun usage patterns can vary by industry, region, or resume-writing convention.
  • โš spaCy's PERSON entity detection can occasionally miss a name formatted unusually (all caps, or with unusual punctuation) โ€” this is a real, honest limitation worth spot-checking directly on your own resume collection before assuming complete coverage.
  • โš This step measures structural variation specifically to justify the pipeline's technique choices in the next steps โ€” it is not meant to be a complete resume-formatting study, only enough evidence to confirm coreference resolution is genuinely needed here, not an unnecessary addition.

Step 2 โ€” Extracting Fixed-Format Fields and Named Entities

This step combines Module 2's exact regex patterns for fixed-format fields (email, phone, graduation year) with Module 6's real spaCy NER for the candidate's name, organizations worked at, and dates โ€” the foundational layer every later extraction step builds on.

02_regex_and_ner_extraction.py
python
1import re
2import spacy
3import warnings
4warnings.filterwarnings("ignore")
5
6nlp = spacy.load("en_core_web_sm")
7
8print("=== EXTRACTING FIXED-FORMAT FIELDS AND NAMED ENTITIES ===\n")
9
10sample_resume = """
11Priya Nair
12priya.nair@email.com | (555) 234-5678
13
14EDUCATION
15Bachelor of Technology, Computer Science, IIT Bombay, 2018
16
17EXPERIENCE
18Software Engineer at Google, 2019-2022
19She led a team of 6 engineers on the search infrastructure project.
20Later, she joined Microsoft as a Senior Engineer in 2022.
21Her work at Microsoft focused on distributed systems reliability.
22"""
23
24# Module 2's exact regex patterns for fixed-format fields
25def extract_regex_fields(text: str) -> dict:
26    email_pattern = r"[\w.+-]+@[\w-]+\.[\w.-]+"
27    phone_pattern = r"\(?\d{3}\)?[-. ]?\d{3}[-. ]?\d{4}"
28    year_pattern = r"\b(19|20)\d{2}\b"
29
30    return {
31        "emails": re.findall(email_pattern, text),
32        "phones": re.findall(phone_pattern, text),
33        "years_mentioned": re.findall(year_pattern, text),
34    }
35
36regex_fields = extract_regex_fields(sample_resume)
37print("=== REGEX-EXTRACTED FIELDS (Module 2's exact patterns) ===\n")
38for field, values in regex_fields.items():
39    print(f"  {field}: {values}")
40
41# Module 6's exact NER approach for names, organizations, dates
42doc = nlp(sample_resume)
43print("\n=== NER-EXTRACTED ENTITIES (Module 6's exact approach) ===\n")
44entities_by_type = {}
45for ent in doc.ents:
46    entities_by_type.setdefault(ent.label_, []).append(ent.text)
47
48for label, values in entities_by_type.items():
49    print(f"  {label}: {values}")
50
51print(f"""
52=== WHAT'S STILL MISSING ===
53
54NER correctly found "Priya Nair" (PERSON), "Google" and "Microsoft"
55(ORG), and the relevant dates -- but notice the two accomplishment
56sentences ("She led a team...", "Her work at Microsoft focused on...")
57have NO explicit connection back to "Priya Nair" in this output. This
58is exactly the gap Step 3's coreference resolution closes.
59""")

Gotchas

  • โš The year_pattern regex matches any 4-digit number starting with 19 or 20, which could incorrectly match an unrelated number that happens to look like a year โ€” a real production system would likely combine this with NER's own DATE entity detection for cross-validation, following the exact combined-technique philosophy this project applies throughout.
  • โš NER alone correctly identifies organizations and the candidate's name as separate, isolated entities, but establishes no relationship between them โ€” confirming directly that entity detection and fact extraction are genuinely separate problems, exactly as this project's architecture decisions describe.
  • โš This sample resume is deliberately structured to include both a directly-named accomplishment style and a pronoun-referenced one, specifically to make the coming coreference resolution step's value directly demonstrable, not an artificially easy or hard case.

Step 3 โ€” Resolving Coreferences and Extracting Structured Facts

This step applies Module 7's exact coreference resolution to link every pronoun reference back to the candidate's name, then applies Module 8's exact dependency parsing to extract genuine subject-verb-object accomplishment facts from the now-resolved text โ€” turning 'she led a team of 6 engineers' into a structured fact directly attributed to Priya Nair.

Resolve the Pronoun, Then Extract the Fact It Was Hiding

Coreference resolution links 'she' back to the candidate's name first. Only then can dependency parsing correctly attribute the accomplishment to the right person, rather than leaving it as an unattributed, disconnected fact.

Resolve First, Then Extract "She led a team of 6 engineers" "she" unresolved coreference resolution "Priya Nair led a team of 6 engineers" now correctly attributed Dependency Parsing subject: Priya Nair verb: led object: a team of 6 engineers structured, attributed fact
03_coreference_and_fact_extraction.py
python
1import spacy
2import coreferee
3import warnings
4warnings.filterwarnings("ignore")
5
6nlp = spacy.load("en_core_web_sm")
7nlp.add_pipe("coreferee")
8
9print("=== RESOLVING COREFERENCES, MODULE 7's EXACT APPROACH ===\n")
10
11sample_resume = """
12Priya Nair worked as a Software Engineer at Google starting in 2019.
13She led a team of 6 engineers on the search infrastructure project.
14Later, she joined Microsoft as a Senior Engineer in 2022.
15Her work at Microsoft focused on distributed systems reliability.
16"""
17
18doc = nlp(sample_resume)
19
20def resolve_coreferences(doc) -> str:
21    """Module 7 Lesson 2's exact resolution function."""
22    resolved_tokens = [token.text_with_ws for token in doc]
23    for chain in doc._.coref_chains:
24        antecedent_index = chain[0].root_index
25        antecedent_text = doc[antecedent_index].text
26        for mention in chain[1:]:
27            mention_index = mention.root_index
28            resolved_tokens[mention_index] = antecedent_text + doc[mention_index].whitespace_
29    return "".join(resolved_tokens)
30
31resolved_text = resolve_coreferences(doc)
32print(f"Original:\n{sample_resume}\n")
33print(f"Resolved (pronouns replaced with the candidate's name):\n{resolved_text}\n")
34
35print("=== EXTRACTING SUBJECT-VERB-OBJECT FACTS, MODULE 8's EXACT APPROACH ===\n")
36
37resolved_doc = nlp(resolved_text)
38
39def extract_svo_triples(doc) -> list:
40    """Module 8 Lesson 2's exact extraction function."""
41    triples = []
42    for token in doc:
43        if token.pos_ == "VERB":
44            subject = None
45            direct_object = None
46            for child in token.children:
47                if child.dep_ == "nsubj":
48                    subject = child.text
49                elif child.dep_ == "dobj":
50                    direct_object = child.text
51            if subject and direct_object:
52                triples.append((subject, token.text, direct_object))
53    return triples
54
55facts = extract_svo_triples(resolved_doc)
56print("Extracted, correctly-attributed facts:\n")
57for subject, verb, obj in facts:
58    print(f"  {subject} -- {verb} -- {obj}")
59
60print(f"""
61=== THE COMPLETE, MEASURED PAYOFF ===
62
63Every extracted fact above is now correctly attributed to "Priya
64Nair" by NAME, not left as an unattributed "she did X" -- this is
65the direct, concrete value of resolving coreferences BEFORE running
66fact extraction, rather than running extraction on the raw text and
67losing every pronoun-attributed accomplishment entirely.
68""")

Gotchas

  • โš This step deliberately reuses Module 7 Lesson 2's exact resolve_coreferences function and Module 8 Lesson 2's exact extract_svo_triples function unchanged โ€” proving these individually-taught functions genuinely compose into a real pipeline, rather than needing to be rewritten for this specific project.
  • โš Running dependency parsing on the RESOLVED text, not the original text, is essential โ€” running Module 8's extraction on the original text would produce facts attributed to the pronoun itself ('she' as the subject), not the candidate's actual name.
  • โš Some accomplishment facts use more complex sentence structures than a simple subject-verb-object triple can capture (like the sentence about distributed systems reliability, which uses 'focused on' as a prepositional phrase rather than a direct object) โ€” this simple extractor, following Module 8's own honest limitation, will miss these, a real, acknowledged gap rather than a claim of complete coverage.

Step 4 โ€” Assembling One Complete, Structured Candidate Record

This step combines every technique from Steps 2 and 3 into one ordered pipeline function, following Module 18's exact production pipeline pattern, producing one complete structured JSON record per resume โ€” ready to be stored in a real, searchable candidate database.

04_complete_extraction_pipeline.py
python
1import re
2import spacy
3import coreferee
4import warnings
5warnings.filterwarnings("ignore")
6
7nlp = spacy.load("en_core_web_sm")
8nlp.add_pipe("coreferee")
9
10print("=== ONE COMPLETE, ORDERED EXTRACTION PIPELINE ===\n")
11
12def extract_regex_fields(text: str) -> dict:
13    return {
14        "emails": re.findall(r"[\w.+-]+@[\w-]+\.[\w.-]+", text),
15        "phones": re.findall(r"\(?\d{3}\)?[-. ]?\d{3}[-. ]?\d{4}", text),
16    }
17
18def resolve_coreferences(doc) -> str:
19    resolved_tokens = [token.text_with_ws for token in doc]
20    for chain in doc._.coref_chains:
21        antecedent_index = chain[0].root_index
22        antecedent_text = doc[antecedent_index].text
23        for mention in chain[1:]:
24            mention_index = mention.root_index
25            resolved_tokens[mention_index] = antecedent_text + doc[mention_index].whitespace_
26    return "".join(resolved_tokens)
27
28def extract_svo_triples(doc) -> list:
29    triples = []
30    for token in doc:
31        if token.pos_ == "VERB":
32            subject, direct_object = None, None
33            for child in token.children:
34                if child.dep_ == "nsubj":
35                    subject = child.text
36                elif child.dep_ == "dobj":
37                    direct_object = child.text
38            if subject and direct_object:
39                triples.append(f"{subject} {token.text} {direct_object}")
40    return triples
41
42def extract_structured_record(resume_text: str) -> dict:
43    """The complete, ordered pipeline: regex fields, NER, coreference
44    resolution, then fact extraction -- following Module 18's exact
45    production pipeline pattern."""
46
47    regex_fields = extract_regex_fields(resume_text)
48
49    initial_doc = nlp(resume_text)
50    person_names = [ent.text for ent in initial_doc.ents if ent.label_ == "PERSON"]
51    organizations = [ent.text for ent in initial_doc.ents if ent.label_ == "ORG"]
52
53    resolved_text = resolve_coreferences(initial_doc)
54    resolved_doc = nlp(resolved_text)
55    accomplishment_facts = extract_svo_triples(resolved_doc)
56
57    return {
58        "candidate_name": person_names[0] if person_names else None,
59        "emails": regex_fields["emails"],
60        "phones": regex_fields["phones"],
61        "organizations_mentioned": list(set(organizations)),
62        "accomplishment_facts": accomplishment_facts,
63    }
64
65sample_resume = """
66Priya Nair
67priya.nair@email.com | (555) 234-5678
68
69Priya Nair worked as a Software Engineer at Google starting in 2019.
70She led a team of 6 engineers on the search infrastructure project.
71Later, she joined Microsoft as a Senior Engineer in 2022.
72"""
73
74record = extract_structured_record(sample_resume)
75
76print("=== FINAL STRUCTURED CANDIDATE RECORD ===\n")
77for field, value in record.items():
78    print(f"  {field}: {value}")
79
80print("""
81This complete, structured record is now ready to be stored in a
82real, searchable candidate database -- turning unstructured resume
83text into filterable fields (search by organization, by
84accomplishment keyword, by contact info) automatically, exactly the
85mechanism behind real applicant tracking systems.
86""")

Gotchas

  • โš Taking person_names[0] as the candidate's name assumes the FIRST detected PERSON entity is the candidate โ€” this is a reasonable heuristic since resumes typically open with the candidate's own name, but a resume mentioning a reference's name before the candidate's own name (an unusual but possible format) would break this assumption, worth checking directly on a larger real sample.
  • โš This pipeline processes one resume at a time for clarity โ€” a real production system processing thousands of resumes would batch this processing and likely run it as an asynchronous job queue rather than a synchronous per-resume call, a genuine scaling consideration beyond this project's illustrative scope.
  • โš accomplishment_facts here inherits every limitation already disclosed in Step 3 (simple SVO structure only) โ€” a genuinely complete production system would need a more sophisticated fact extraction approach to capture the full range of ways resumes describe accomplishments.

Step 5 โ€” Serving the Extraction Pipeline as a Real API

Following Module 18's exact FastAPI serving pattern, this final step wraps the complete extraction pipeline in a real API endpoint that accepts raw resume text and returns the structured candidate record, ready for a real HR system to call directly.

05_serve_extraction_api.py
python
1from fastapi import FastAPI
2from pydantic import BaseModel
3import spacy
4import coreferee
5import re
6
7app = FastAPI(title="Resume Information Extraction API")
8
9nlp = None
10
11@app.on_event("startup")
12def load_model():
13    global nlp
14    nlp = spacy.load("en_core_web_sm")
15    nlp.add_pipe("coreferee")
16    print("Resume extraction pipeline loaded.")
17
18class ResumeRequest(BaseModel):
19    text: str
20
21def extract_regex_fields(text: str) -> dict:
22    return {
23        "emails": re.findall(r"[\w.+-]+@[\w-]+\.[\w.-]+", text),
24        "phones": re.findall(r"\(?\d{3}\)?[-. ]?\d{3}[-. ]?\d{4}", text),
25    }
26
27def resolve_coreferences(doc) -> str:
28    resolved_tokens = [token.text_with_ws for token in doc]
29    for chain in doc._.coref_chains:
30        antecedent_index = chain[0].root_index
31        antecedent_text = doc[antecedent_index].text
32        for mention in chain[1:]:
33            mention_index = mention.root_index
34            resolved_tokens[mention_index] = antecedent_text + doc[mention_index].whitespace_
35    return "".join(resolved_tokens)
36
37def extract_svo_triples(doc) -> list:
38    triples = []
39    for token in doc:
40        if token.pos_ == "VERB":
41            subject, direct_object = None, None
42            for child in token.children:
43                if child.dep_ == "nsubj":
44                    subject = child.text
45                elif child.dep_ == "dobj":
46                    direct_object = child.text
47            if subject and direct_object:
48                triples.append(f"{subject} {token.text} {direct_object}")
49    return triples
50
51@app.post("/extract-resume")
52def extract_resume(request: ResumeRequest):
53    regex_fields = extract_regex_fields(request.text)
54
55    initial_doc = nlp(request.text)
56    person_names = [ent.text for ent in initial_doc.ents if ent.label_ == "PERSON"]
57    organizations = [ent.text for ent in initial_doc.ents if ent.label_ == "ORG"]
58
59    resolved_text = resolve_coreferences(initial_doc)
60    resolved_doc = nlp(resolved_text)
61    accomplishment_facts = extract_svo_triples(resolved_doc)
62
63    return {
64        "candidate_name": person_names[0] if person_names else None,
65        "emails": regex_fields["emails"],
66        "phones": regex_fields["phones"],
67        "organizations_mentioned": list(set(organizations)),
68        "accomplishment_facts": accomplishment_facts,
69    }
70
71# Run with: uvicorn 05_serve_extraction_api:app --host 0.0.0.0 --port 8000

Gotchas

  • โš Loading spaCy and adding the coreferee pipeline component happens once, at server startup, following the exact load-once discipline established since DL Module 37 and reinforced in Module 18 โ€” never reloaded inside the request handler.
  • โš This endpoint accepts raw resume text directly โ€” a real production system would need a separate document-parsing step (converting PDF or Word resumes to plain text) before this endpoint, a genuinely necessary but different problem this project's NLP-focused scope doesn't address.
  • โš Every function in this server is copied directly from Steps 2-4 unchanged โ€” a real production codebase would factor these into a shared module imported by both training/testing scripts and the server, rather than duplicating the code, following the same DRY caution raised in the Deep Learning course's audio project.