Build a Responsible Candidate Ranker

Build a local app to rank synthetic candidates and monitor outcomes.

Introduction

30 Second Summary

Candidate shortlists can look precise even when the choices behind their order are hard to see. In hiring, that hidden logic can shape who gets attention before anyone checks the outcome.

In this project, you will build a local Streamlit app that ranks synthetic candidates with visible score explanations. Selection-rate monitoring highlights cohort gaps for human oversight.

What You'll Build

Picture changing a job requirement in your browser to watch the explained shortlist update beside its outcome check.

By the end of this project, you'll have:

  • An interactive candidate table with controls for required skills, location, and minimum experience.
  • A ranked shortlist with a visible score plus a readable reason for every candidate.
  • A cohort outcome chart with selection rates plus a review warning triggered by the demo's 20 percentage-point rule.
  • Secret Mission: A criteria weight simulator that reveals how changing the criteria weights affects rankings plus cohort outcomes.

Are there any prerequisites?

Basic Python syntax is helpful. The app runs locally on the listed Windows setup without a cloud account or API key.

Before We Start

Responsible hiring tools should support human judgment without making employment decisions. This checkpoint defines what your local learning app recommends and why a person remains accountable for reviewing its shortlist.

Set Up the Local Streamlit App

Local Python projects can collide when they share package versions. A dedicated virtual environment protects this app from those conflicts.

You will prepare the workspace in Visual Studio Code with PowerShell. You will install Streamlit plus pandas before launching a known-good page.

In this step, get ready to:
  • Confirm that your Python version is compatible.
  • Create an isolated environment for the project.
  • Install the pinned packages before launching the starter app.
Check Python and open the project

Streamlit and pandas require a compatible Python runtime. Checking the version first prevents confusing installation failures later.

  • Press the Windows key to open search.
  • Type Visual Studio Code into the search field.
  • Press Enter to open Visual Studio Code.
  • Click View in the menu bar.
  • Select Terminal to display the integrated terminal.
  • Select PowerShell from the terminal profile menu if the terminal tab shows a different shell.
  • Check your installed Python version by running this command:
py -3 --version

What Does This Command Check?

The Python launcher prints the version selected for Python 3. The project supports Python 3.11 through 3.15.

✔️ I see a compatible version

Good, your Python runtime is ready for the pinned packages.

  • Continue with the project folder instructions below.

ⓧ I see an older or newer version

Your installed version is outside the supported range. Install the current stable Python 3.15.0 release before creating the environment.

  • Visit the official Python for Windows page.
  • Select Download Windows installer (64-bit) for Python 3.15.0.
  • Run the downloaded installer.
  • Complete the installer using its recommended settings.
  • Close Visual Studio Code after the installation finishes.
  • Reopen Visual Studio Code through Windows search.
  • Repeat the version check shown above.

ⓧ Command not found

The Python launcher is unavailable. Install Python 3.15.0 before continuing.

  • Visit the official Python for Windows page.
  • Select Download Windows installer (64-bit) for Python 3.15.0.
  • Run the downloaded installer.
  • Complete the installer using its recommended settings.
  • Close Visual Studio Code after the installation finishes.
  • Reopen Visual Studio Code through Windows search.
  • Repeat the version check shown above.

A named workspace keeps the environment files beside the app they support. Visual Studio Code also starts its integrated terminal inside the folder you open.

  • Click File in the Visual Studio Code menu bar.
  • Select Open Folder.
  • Navigate to your Desktop in the folder picker.
  • Use the folder-creation control to create responsible-candidate-ranker.
  • Select the responsible-candidate-ranker folder.
  • Confirm the selection to open the folder.
  • Confirm that you trust the folder if Visual Studio Code displays a workspace trust prompt.

You should see responsible-candidate-ranker at the top of the Explorer sidebar. The integrated terminal now uses that folder as its starting location.

Create and activate the environment

The environment stores this project's packages inside .venv. Activating it directs installation commands to that isolated location.

  • Create .venv before activating it by running these commands in the PowerShell terminal:
python -m venv .venv
.venv\Scripts\Activate.ps1

What Do These Commands Do?

  • The first command creates an isolated Python environment inside the .venv folder.
  • The second command updates the current PowerShell session to use that environment.

Choose the tab that matches what PowerShell shows after the activation command.

✔️ I see the environment name

That is the environment working. Your terminal prompt now starts with (.venv).

ⓧ PowerShell blocks the script

PowerShell can block local activation scripts until your current-user policy allows them. This setting applies to your Windows user account.

  • Allow locally created activation scripts by running the documented current-user command:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

What Does This Command Change?

The command changes the execution policy for your current Windows user. It allows the virtual environment's local activation script to run.

  • Activate the environment again by running:
.venv\Scripts\Activate.ps1

What Should You See?

The terminal prompt should begin with (.venv). This confirms that package installations now target the project environment.

Still Unable to Activate the Environment?

  • Confirm that the terminal profile is PowerShell.
  • Confirm that .venv appears in the Explorer sidebar.
  • Confirm that Visual Studio Code is open to responsible-candidate-ranker.

Get help with the activation issue through this troubleshooting prompt.

Install the packages and launch the starter

Pinned dependencies give every learner the same application behavior. The requirements file records the exact package versions for this project.

  • Select the New File button in the Explorer toolbar.
  • Enter requirements.txt as the file name.

The Explorer sidebar should now list requirements.txt inside responsible-candidate-ranker.

  • Add the pinned dependencies to requirements.txt by pasting this content:
streamlit==1.66.0
pandas==3.0.6

Why Pin These Versions?

  • Streamlit 1.66.0 provides the local web app interface.
  • pandas 3.0.6 provides the table operations used by the candidate ranker.
  • Exact pins keep the project behavior consistent across installations.
  • Save requirements.txt by pressing Ctrl+S.

The first package installation may pause while files download. A short wait is normal.

  • Install the pinned dependencies from requirements.txt by running:
python -m pip install -r requirements.txt

What Does This Command Do?

Python runs pip from the activated environment. Pip reads requirements.txt before installing both pinned packages into .venv.

Your PowerShell prompt returns after the installation completes. That means the environment contains the dependencies needed by the starter app.

Package Installation Failed?

  • Confirm that the prompt begins with (.venv).
  • Confirm that requirements.txt contains both pinned dependency lines.
  • Confirm that your Python version remains between 3.11 and 3.15.

Use this installation troubleshooting prompt for environment-specific help.

The starter page gives you a visible baseline before any ranking logic is added. Its caption also preserves the project's decision-support boundary.

  • Select the New File button in the Explorer toolbar.
  • Enter app.py as the file name.

The Explorer sidebar should now show app.py beside requirements.txt.

  • Add the starter application to app.py by pasting this code:
import pandas as pd
import streamlit as st

st.set_page_config(page_title="Transparent Candidate Ranker", layout="wide")
st.title("Transparent Candidate Ranker")
st.caption("Synthetic data and a rule-based score for learning only. This app must not make employment decisions.")

What Does This Code Do?

  • The imports make pandas available as pd and Streamlit available as st.
  • The page configuration gives the app its browser title and a wide layout.
  • The title names the product shown in the browser.
  • The caption states that the data is synthetic and the score cannot make employment decisions.
  • Save app.py by pressing Ctrl+S.

Before you launch it, predict whether the browser will show a working page or an error.

  • Launch the starter app from the activated environment by running:
python -m streamlit run app.py

What Happens When the App Starts?

Python starts the local Streamlit server from app.py. Streamlit opens the app in your default browser.

  • Keep the PowerShell terminal running while you use the app.
  • Press Ctrl+C in that terminal when you need to stop the server later.

Your browser should show the title Transparent Candidate Ranker. You should also see the synthetic-data notice directly below it.

You have a working local app. This clean starting point proves the environment before candidate data enters the project.

Starter Page Not Opening?

  • Confirm that the PowerShell prompt showed (.venv) before you launched the app.
  • Confirm that app.py and requirements.txt are both inside responsible-candidate-ranker.
  • Compare the import names in app.py with the starter code above.

Use this app launch troubleshooting prompt for targeted help.

✔️ Awesome, I've got everything!

Great, both files are saved and the starter app is running in your browser.

ⓧ I'd like to double check the full code

Compare your pinned dependencies with this complete requirements.txt file.

streamlit==1.66.0
pandas==3.0.6

Compare your starter application with this complete app.py file.

import pandas as pd
import streamlit as st

st.set_page_config(page_title="Transparent Candidate Ranker", layout="wide")
st.title("Transparent Candidate Ranker")
st.caption("Synthetic data and a rule-based score for learning only. This app must not make employment decisions.")

Your isolated environment and starter page are ready. Next, you will turn the synthetic candidate records into the app's first interactive table.

Show the Candidate Pool

Your local Streamlit starter is running. The page still has no candidates to review.

This step adds a synthetic candidate pool to the app. You will display every record in an interactive table.

In this step, get ready to:
  • Add the synthetic candidate records and supporting constants.
  • Display all 12 candidates in an interactive table.
Add the synthetic candidate records

A consistent dataset gives every learner the same candidates to review. The cohort field stays available only for the outcome audit.

  • In app.py, place the candidate data below the two import lines by adding this code:
CANDIDATES = [
    {"candidate_id": "C001", "candidate": "Ava Chen", "cohort": "Cohort A", "location": "Seattle", "years_experience": 6, "skills": "Python, SQL, Tableau"},
    {"candidate_id": "C002", "candidate": "Ben Ortiz", "cohort": "Cohort A", "location": "Denver", "years_experience": 5, "skills": "Python, SQL, Excel"},
    {"candidate_id": "C003", "candidate": "Cara Singh", "cohort": "Cohort B", "location": "Seattle", "years_experience": 4, "skills": "SQL, Tableau, Excel"},
    {"candidate_id": "C004", "candidate": "Diego Ruiz", "cohort": "Cohort A", "location": "Austin", "years_experience": 7, "skills": "Python, SQL, Statistics"},
    {"candidate_id": "C005", "candidate": "Erin Brooks", "cohort": "Cohort B", "location": "Denver", "years_experience": 3, "skills": "Python, Excel"},
    {"candidate_id": "C006", "candidate": "Farah Ali", "cohort": "Cohort B", "location": "Austin", "years_experience": 5, "skills": "SQL, Statistics, Tableau"},
    {"candidate_id": "C007", "candidate": "Gabe Wilson", "cohort": "Cohort A", "location": "Seattle", "years_experience": 8, "skills": "Python, SQL, Tableau, Statistics"},
    {"candidate_id": "C008", "candidate": "Hana Kim", "cohort": "Cohort B", "location": "Denver", "years_experience": 2, "skills": "Excel, Tableau"},
    {"candidate_id": "C009", "candidate": "Ivan Petrov", "cohort": "Cohort A", "location": "Austin", "years_experience": 4, "skills": "Python, SQL, Excel"},
    {"candidate_id": "C010", "candidate": "Jules Martin", "cohort": "Cohort B", "location": "Seattle", "years_experience": 6, "skills": "Python, Statistics, Excel"},
    {"candidate_id": "C011", "candidate": "Kai Morgan", "cohort": "Cohort A", "location": "Denver", "years_experience": 3, "skills": "SQL, Tableau"},
    {"candidate_id": "C012", "candidate": "Lina Haddad", "cohort": "Cohort B", "location": "Austin", "years_experience": 7, "skills": "Python, SQL, Tableau, Excel"},
]

SKILLS = ["Excel", "Python", "SQL", "Statistics", "Tableau"]
LOCATIONS = sorted({candidate["location"] for candidate in CANDIDATES})
DISPLAY_COLUMNS = ["candidate_id", "candidate", "location", "years_experience", "skills", "score", "reason"]

What Do These Constants Store?

  • The CANDIDATES list holds 12 fictional records with consistent fields.
  • The SKILLS list defines the available skills in a stable order.
  • The LOCATIONS set expression collects each unique candidate location.
  • The DISPLAY_COLUMNS list defines the fields that the ranked shortlist will eventually show.

The cohort value is synthetic audit data. It does not describe job fit.

  • Save app.py.
  • Switch back to the PowerShell terminal from earlier.

You should see the running app continue without a Python error after the save.

Seeing a Python Error?

  • Check that every candidate record ends with a comma.
  • Check that the closing square bracket appears immediately above SKILLS.
  • Compare the quotation marks around each field name with the code above.

Need help finding the syntax problem in your candidate records?

Turn the records into a table

A pandas DataFrame turns the records into labeled rows and columns. Streamlit can render that structure as an interactive browser table.

  • At the bottom of app.py, add the table code below the existing caption:
candidate_table = pd.DataFrame(CANDIDATES)
st.dataframe(candidate_table, hide_index=True, width="stretch")

What Does This Code Do?

  • The candidate_table variable stores the candidate records as a table.
  • The st.dataframe call renders an interactive view in the browser.
  • The hide_index=True setting removes the extra row-number column.
  • The width="stretch" setting uses the available page width.

Before you save, how many candidate rows do you expect the browser table to contain?

  • Save app.py.
  • Switch back to the browser tab from earlier.

You should see 12 candidate rows with IDs, names, cohorts, locations, experience values, and skills. The row-number index should be hidden.

Do Not See the Candidate Table?

  • Confirm that the two table lines sit below the existing st.caption line.
  • Confirm that candidate_table uses the same spelling in both new lines.
  • Check the PowerShell terminal for the first reported Python error.

Need help getting the candidate table to render?

✔️ Awesome, I've got everything!

Your app.py file is saved. The browser now shows the full synthetic candidate pool.

ⓧ I'd like to double check the full code

import pandas as pd
import streamlit as st

CANDIDATES = [
    {"candidate_id": "C001", "candidate": "Ava Chen", "cohort": "Cohort A", "location": "Seattle", "years_experience": 6, "skills": "Python, SQL, Tableau"},
    {"candidate_id": "C002", "candidate": "Ben Ortiz", "cohort": "Cohort A", "location": "Denver", "years_experience": 5, "skills": "Python, SQL, Excel"},
    {"candidate_id": "C003", "candidate": "Cara Singh", "cohort": "Cohort B", "location": "Seattle", "years_experience": 4, "skills": "SQL, Tableau, Excel"},
    {"candidate_id": "C004", "candidate": "Diego Ruiz", "cohort": "Cohort A", "location": "Austin", "years_experience": 7, "skills": "Python, SQL, Statistics"},
    {"candidate_id": "C005", "candidate": "Erin Brooks", "cohort": "Cohort B", "location": "Denver", "years_experience": 3, "skills": "Python, Excel"},
    {"candidate_id": "C006", "candidate": "Farah Ali", "cohort": "Cohort B", "location": "Austin", "years_experience": 5, "skills": "SQL, Statistics, Tableau"},
    {"candidate_id": "C007", "candidate": "Gabe Wilson", "cohort": "Cohort A", "location": "Seattle", "years_experience": 8, "skills": "Python, SQL, Tableau, Statistics"},
    {"candidate_id": "C008", "candidate": "Hana Kim", "cohort": "Cohort B", "location": "Denver", "years_experience": 2, "skills": "Excel, Tableau"},
    {"candidate_id": "C009", "candidate": "Ivan Petrov", "cohort": "Cohort A", "location": "Austin", "years_experience": 4, "skills": "Python, SQL, Excel"},
    {"candidate_id": "C010", "candidate": "Jules Martin", "cohort": "Cohort B", "location": "Seattle", "years_experience": 6, "skills": "Python, Statistics, Excel"},
    {"candidate_id": "C011", "candidate": "Kai Morgan", "cohort": "Cohort A", "location": "Denver", "years_experience": 3, "skills": "SQL, Tableau"},
    {"candidate_id": "C012", "candidate": "Lina Haddad", "cohort": "Cohort B", "location": "Austin", "years_experience": 7, "skills": "Python, SQL, Tableau, Excel"},
]

SKILLS = ["Excel", "Python", "SQL", "Statistics", "Tableau"]
LOCATIONS = sorted({candidate["location"] for candidate in CANDIDATES})
DISPLAY_COLUMNS = ["candidate_id", "candidate", "location", "years_experience", "skills", "score", "reason"]

st.set_page_config(page_title="Transparent Candidate Ranker", layout="wide")
st.title("Transparent Candidate Ranker")
st.caption("Synthetic data and a rule-based score for learning only. This app must not make employment decisions.")

candidate_table = pd.DataFrame(CANDIDATES)
st.dataframe(candidate_table, hide_index=True, width="stretch")

Before you scan the table, do you expect the five strongest candidates to be obvious at a glance?

  • Use only the table to choose five candidates who seem strongest based on skills and experience.

The records force you to compare several fields manually. Different reviewers can weigh the same details differently.

That inconsistency is the intended shortfall. The table gives you visibility without a repeatable ranking method.

You have your first working product view. Next, you will replace manual scanning with a transparent score and a readable reason for every shortlisted candidate.

Build the Transparent Ranker

You can now inspect all 12 synthetic candidates in one table. Manual scanning is slow. It can also produce inconsistent shortlists.

This step adds transparent rule-based ranking with readable reasons. The resulting shortlist still cannot reveal uneven cohort outcomes because it has no outcome audit.

In this step, get ready to:
  • Add interactive job-requirement controls.
  • Score eligible candidates with job-related criteria.
  • Display a ranked shortlist with inspectable metrics.
Add the job-requirement controls

The raw candidate table treats every role as though it has the same requirements. Streamlit widgets let you define the skills threshold before the app calculates a shortlist.

  • In app.py, find the line that creates candidate_table.
  • Delete the candidate_table = pd.DataFrame(CANDIDATES) line.
  • Delete the following line that renders candidate_table.
  • Place your cursor below the synthetic-data caption.
  • Add the requirement controls by pasting this code:
required_skills = st.multiselect("Required skills", SKILLS, default=["Python", "SQL"])
selected_locations = st.multiselect("Locations", LOCATIONS, default=LOCATIONS)
min_experience = st.slider("Minimum years of experience", 0, 8, 2)
top_n = st.slider("Shortlist size", 1, 8, 5)
st.caption("Demo score: 70% required-skill match + 30% experience, capped at 8 years. Cohort is audit-only and is never used for ranking.")

What Do These Controls Change?

  • The required_skills control starts with Python plus SQL.
  • The selected_locations control includes every location by default.
  • The min_experience slider starts at 2 years.
  • The top_n slider starts with a shortlist size of 5.
  • The caption makes the scoring formula visible before anyone interprets the results.
  • Save app.py.
  • Return to the browser tab running your app.

You'll see controls for required skills, locations, minimum experience, and shortlist size. You'll also see the fixed scoring formula below them.

Good progress. Your app can now capture the job-related criteria that shape its shortlist.

Don't See the Controls?

  • Confirm that you saved app.py inside the responsible-candidate-ranker folder.
  • Check that each control appears below the synthetic-data caption.
  • Compare the widget labels with the code block above because mismatched quotes can stop the app from rerunning.

Help me troubleshoot missing Streamlit controls in app.py.

Score only job-related criteria

A transparent score exposes its ingredients through a readable explanation. This scorer uses skill match plus experience while keeping cohort outside the calculation.

  • In app.py, place your cursor below DISPLAY_COLUMNS.
  • Add the candidate scoring function by pasting this code:
def score_candidate(candidate, required_skills):
    candidate_skills = {skill.strip() for skill in candidate["skills"].split(",")}
    matched_skills = sorted(set(required_skills) & candidate_skills)
    skill_score = len(matched_skills) / len(required_skills) if required_skills else 1.0
    experience_score = min(candidate["years_experience"] / 8, 1.0)
    total_score = round((0.7 * skill_score + 0.3 * experience_score) * 100, 1)
    match_text = f"{len(matched_skills)}/{len(required_skills)} required skills matched" if required_skills else "No required skills selected"
    reason = f"{match_text}; {candidate['years_experience']} years experience"
    return total_score, reason

How Does the Score Stay Transparent?

  • The candidate_skills set converts the stored skills into individual values.
  • The matched_skills list records which required skills appear in the candidate record.
  • The skill_score contributes 70% of the total.
  • The experience_score contributes 30% of the total with an 8-year cap.
  • The reason value exposes the matched-skill count plus the candidate's experience.
  • Save app.py.
  • Return to the browser tab running your app.

You'll still see the four controls without a Python error. This confirms that score_candidate loads successfully.

Seeing a Python Error?

  • Check that every line inside score_candidate has four spaces of indentation.
  • Confirm that candidate_skills uses the existing skills field.
  • Check that score_candidate appears above st.set_page_config.

Help me debug the score_candidate function.

The scoring function handles one candidate at a time. The ranking function applies it to every eligible record before sorting the results.

  • Place your cursor below the completed score_candidate function.
  • Add the ranking pipeline by pasting this code:
def build_ranked_candidates(required_skills, selected_locations, min_experience):
    scored_candidates = []
    for candidate in CANDIDATES:
        location_matches = candidate["location"] in selected_locations
        experience_matches = candidate["years_experience"] >= min_experience
        if location_matches and experience_matches:
            score, reason = score_candidate(candidate, required_skills)
            scored_candidates.append({**candidate, "score": score, "reason": reason})
    if not scored_candidates:
        return pd.DataFrame(columns=DISPLAY_COLUMNS + ["cohort"])
    return pd.DataFrame(scored_candidates).sort_values(by=["score", "candidate_id"], ascending=[False, True], ignore_index=True)

How Does the Ranking Pipeline Work?

  • The location_matches value checks the selected locations.
  • The experience_matches value checks the minimum experience threshold.
  • The scored_candidates list stores eligible records with their score explanations.
  • A pandas DataFrame provides the empty result when no records qualify.
  • The final sort puts the highest score first. Candidate ID provides a stable tie-breaker.
  • Save app.py.
  • Return to the browser tab running your app.

You'll still see the requirement controls without an error. This confirms that both ranking functions load before the interface runs.

Ranking Function Not Loading?

  • Check that build_ranked_candidates appears below score_candidate.
  • Confirm that the two lines inside the candidate loop use eight spaces of indentation.
  • Check that pd.DataFrame uses the existing pd import.

Help me debug the build_ranked_candidates function.

Display the transparent shortlist

The two functions now produce ranked data with an explanation for every score. The interface needs to turn that data into a shortlist that a person can inspect.

  • In app.py, place your cursor below the scoring-formula caption.
  • Add the shortlist display by pasting this code:
ranked_candidates = build_ranked_candidates(required_skills, selected_locations, min_experience)
if len(ranked_candidates) == 0:
    st.warning("No candidates match the current filters.")
else:
    shortlist = ranked_candidates.head(top_n)
    average_score = round(sum(shortlist["score"]) / len(shortlist), 1)
    st.metric("Eligible candidates", len(ranked_candidates))
    st.metric("Shortlisted candidates", len(shortlist))
    st.metric("Average shortlist score", average_score)
    st.dataframe(shortlist[DISPLAY_COLUMNS], hide_index=True, width="stretch")

What Does the Interface Display?

  • The ranked_candidates table contains every record that passes the location plus experience filters.
  • The shortlist table keeps the requested number of highest-scoring candidates.
  • The average_score value summarizes the selected rows.
  • The three metrics show the size plus average quality of the current shortlist.
  • The displayed reason column makes each score inspectable.

Before you save, consider which five candidates the default controls will place first.

  • Save app.py.
  • Return to the browser tab running your app.

You'll see Gabe Wilson, Diego Ruiz, Lina Haddad, Ava Chen, and Ben Ortiz in that order. Each row includes a score plus a readable reason.

That's the ranking loop working. Every shortlisted candidate now has an inspectable score explanation.

Shortlist Order Looks Different?

  • Set Required skills to Python plus SQL.
  • Select every option under Locations.
  • Set Minimum years of experience to 2.
  • Set Shortlist size to 5.
  • Check that the final sort uses score first with candidate ID as the tie-breaker.

Help me fix an unexpected shortlist order.

  • Clear every selected option from Locations.

You'll see the warning No candidates match the current filters. This confirms that the app handles an empty ranking without displaying a blank table.

  • Select all three options under Locations.

You'll see the five default candidates return.

Before you inspect the completed page, consider whether this shortlist can reveal a cohort selection-rate gap.

  • Check the page below the shortlist for a cohort selection-rate comparison.

You'll find no cohort comparison. This is the intended shortfall because transparent scores alone cannot reveal uneven shortlist outcomes.

✔️ Awesome, I've got everything!

Great work. Save app.py once more before moving on.

ⓧ I'd like to double check the full code

import pandas as pd
import streamlit as st

CANDIDATES = [
    {"candidate_id": "C001", "candidate": "Ava Chen", "cohort": "Cohort A", "location": "Seattle", "years_experience": 6, "skills": "Python, SQL, Tableau"},
    {"candidate_id": "C002", "candidate": "Ben Ortiz", "cohort": "Cohort A", "location": "Denver", "years_experience": 5, "skills": "Python, SQL, Excel"},
    {"candidate_id": "C003", "candidate": "Cara Singh", "cohort": "Cohort B", "location": "Seattle", "years_experience": 4, "skills": "SQL, Tableau, Excel"},
    {"candidate_id": "C004", "candidate": "Diego Ruiz", "cohort": "Cohort A", "location": "Austin", "years_experience": 7, "skills": "Python, SQL, Statistics"},
    {"candidate_id": "C005", "candidate": "Erin Brooks", "cohort": "Cohort B", "location": "Denver", "years_experience": 3, "skills": "Python, Excel"},
    {"candidate_id": "C006", "candidate": "Farah Ali", "cohort": "Cohort B", "location": "Austin", "years_experience": 5, "skills": "SQL, Statistics, Tableau"},
    {"candidate_id": "C007", "candidate": "Gabe Wilson", "cohort": "Cohort A", "location": "Seattle", "years_experience": 8, "skills": "Python, SQL, Tableau, Statistics"},
    {"candidate_id": "C008", "candidate": "Hana Kim", "cohort": "Cohort B", "location": "Denver", "years_experience": 2, "skills": "Excel, Tableau"},
    {"candidate_id": "C009", "candidate": "Ivan Petrov", "cohort": "Cohort A", "location": "Austin", "years_experience": 4, "skills": "Python, SQL, Excel"},
    {"candidate_id": "C010", "candidate": "Jules Martin", "cohort": "Cohort B", "location": "Seattle", "years_experience": 6, "skills": "Python, Statistics, Excel"},
    {"candidate_id": "C011", "candidate": "Kai Morgan", "cohort": "Cohort A", "location": "Denver", "years_experience": 3, "skills": "SQL, Tableau"},
    {"candidate_id": "C012", "candidate": "Lina Haddad", "cohort": "Cohort B", "location": "Austin", "years_experience": 7, "skills": "Python, SQL, Tableau, Excel"},
]

SKILLS = ["Excel", "Python", "SQL", "Statistics", "Tableau"]
LOCATIONS = sorted({candidate["location"] for candidate in CANDIDATES})
DISPLAY_COLUMNS = ["candidate_id", "candidate", "location", "years_experience", "skills", "score", "reason"]


def score_candidate(candidate, required_skills):
    candidate_skills = {skill.strip() for skill in candidate["skills"].split(",")}
    matched_skills = sorted(set(required_skills) & candidate_skills)
    skill_score = len(matched_skills) / len(required_skills) if required_skills else 1.0
    experience_score = min(candidate["years_experience"] / 8, 1.0)
    total_score = round((0.7 * skill_score + 0.3 * experience_score) * 100, 1)
    match_text = f"{len(matched_skills)}/{len(required_skills)} required skills matched" if required_skills else "No required skills selected"
    reason = f"{match_text}; {candidate['years_experience']} years experience"
    return total_score, reason


def build_ranked_candidates(required_skills, selected_locations, min_experience):
    scored_candidates = []
    for candidate in CANDIDATES:
        location_matches = candidate["location"] in selected_locations
        experience_matches = candidate["years_experience"] >= min_experience
        if location_matches and experience_matches:
            score, reason = score_candidate(candidate, required_skills)
            scored_candidates.append({**candidate, "score": score, "reason": reason})
    if not scored_candidates:
        return pd.DataFrame(columns=DISPLAY_COLUMNS + ["cohort"])
    return pd.DataFrame(scored_candidates).sort_values(by=["score", "candidate_id"], ascending=[False, True], ignore_index=True)


st.set_page_config(page_title="Transparent Candidate Ranker", layout="wide")
st.title("Transparent Candidate Ranker")
st.caption("Synthetic data and a rule-based score for learning only. This app must not make employment decisions.")

required_skills = st.multiselect("Required skills", SKILLS, default=["Python", "SQL"])
selected_locations = st.multiselect("Locations", LOCATIONS, default=LOCATIONS)
min_experience = st.slider("Minimum years of experience", 0, 8, 2)
top_n = st.slider("Shortlist size", 1, 8, 5)
st.caption("Demo score: 70% required-skill match + 30% experience, capped at 8 years. Cohort is audit-only and is never used for ranking.")

ranked_candidates = build_ranked_candidates(required_skills, selected_locations, min_experience)
if len(ranked_candidates) == 0:
    st.warning("No candidates match the current filters.")
else:
    shortlist = ranked_candidates.head(top_n)
    average_score = round(sum(shortlist["score"]) / len(shortlist), 1)
    st.metric("Eligible candidates", len(ranked_candidates))
    st.metric("Shortlisted candidates", len(shortlist))
    st.metric("Average shortlist score", average_score)
    st.dataframe(shortlist[DISPLAY_COLUMNS], hide_index=True, width="stretch")

Your app now turns job-related requirements into an explainable shortlist. Next, you'll measure the cohort outcomes that this ranking view cannot expose.

Add the Outcome Quality Check

Your transparent shortlist now ranks the eligible candidates. Each score includes a readable reason.

The remaining question is whether the shortlist selects the synthetic cohorts at noticeably different rates. Outcome monitoring makes that gap visible while cohort data stays outside scoring.

In this step, get ready to:
  • Calculate audit-only selection rates for each synthetic cohort.
  • Visualize the cohort rates in a bar chart.
  • Flag large gaps for human review.
Calculate selection rates without changing scores

Before you check, predict whether the current page can reveal a cohort selection-rate gap.

  • Return to the running app in your browser.
  • Inspect the shortlist for a cohort-level outcome comparison.

You can see scores beside candidate explanations. The page has no cohort-level outcome comparison.

An audit compares how many eligible candidates from each cohort reach the shortlist. It runs after ranking so the cohort value never enters score_candidate.

  • Switch back to app.py in Visual Studio Code.
  • Find the final return pd.DataFrame(scored_candidates).sort_values(by=["score", "candidate_id"], ascending=[False, True], ignore_index=True) line in build_ranked_candidates.
  • Add the audit function on a blank line below it by pasting this code:
def build_audit(shortlist, ranked_candidates):
    eligible_ids = set(ranked_candidates["candidate_id"])
    selected_ids = set(shortlist["candidate_id"])
    cohorts = sorted({candidate["cohort"] for candidate in CANDIDATES})
    audit_rows = []
    for cohort in cohorts:
        eligible_count = sum(1 for candidate in CANDIDATES if candidate["cohort"] == cohort and candidate["candidate_id"] in eligible_ids)
        selected_count = sum(1 for candidate in CANDIDATES if candidate["cohort"] == cohort and candidate["candidate_id"] in selected_ids)
        if eligible_count > 0:
            selection_rate = round(selected_count / eligible_count * 100, 1)
            audit_rows.append({"cohort": cohort, "eligible": eligible_count, "selected": selected_count, "selection_rate_percent": selection_rate})
    return pd.DataFrame(audit_rows)

How does the audit stay separate?

  • The eligible_ids set identifies candidates who passed the job-related filters.
  • The selected_ids set identifies candidates who reached the shortlist.
  • The cohort loop counts eligible candidates separately from selected candidates.
  • The selection rate divides each cohort's selected count by its eligible count.
  • Save app.py.
  • Return to the running app in your browser.

You should still see the same top-five order: Gabe Wilson, Diego Ruiz, Lina Haddad, Ava Chen, and Ben Ortiz. This confirms that the audit function has not changed the ranking.

Did the app stop rerunning?

  • Check that build_audit starts at the left edge of app.py.
  • Check that every line inside build_audit uses consistent indentation.

Need help fixing the audit function?

Visualize the outcome check

A percentage makes cohort outcomes easier to compare than raw shortlist counts. The chart places both synthetic cohorts on the same scale.

  • In app.py, find st.dataframe(shortlist[DISPLAY_COLUMNS], hide_index=True, width="stretch") inside the else block.
  • Add the audit display directly below that line by pasting this code:
    audit = build_audit(shortlist, ranked_candidates)
    st.bar_chart(audit, x="cohort", y="selection_rate_percent", y_label="Selection rate (%)", color="cohort")
    rates = list(audit["selection_rate_percent"])

What does this code do?

  • The audit table stores the eligible count plus the selected count for each cohort.
  • The st.bar_chart call plots each cohort against its selection-rate percentage.
  • The rates list prepares the displayed percentages for the review check.

Before you save, predict which cohort will have the higher selection rate.

  • Save app.py.
  • Return to the running app in your browser.

You should see a bar chart below the shortlist. Cohort A shows 66.7%, while Cohort B shows 16.7%.

You have surfaced the hidden outcome, so reviewers can now compare the cohorts directly.

Don't see both cohort bars?

  • Check that the three new lines remain inside the shortlist's else block.
  • Check that audit is created before it is passed to st.bar_chart.
  • Restore every location in the Locations control if a cohort is missing.

Need help restoring the cohort chart?

Add the human-review guardrail

The difference between the highest rate and the lowest rate produces a percentage-point gap. The app uses an internal 20-point demo rule to decide which review message to show.

  • In app.py, locate rates = list(audit["selection_rate_percent"]).
  • Add the review guardrail directly below that line by pasting this code:
    if len(rates) < 2:
        st.warning("The audit needs eligible candidates from at least two cohorts before it can compare selection rates.")
    else:
        rate_gap = round(max(rates) - min(rates), 1)
        if rate_gap > 20:
            st.warning(f"Demo review signal: cohort selection rates differ by {rate_gap} percentage points. This is not a legal finding. A person should review the criteria, data, and outcomes.")
        else:
            st.success(f"The demo selection-rate gap is {rate_gap} percentage points. Continue human review and monitoring before any real use.")

How does the guardrail work?

  • The first condition handles filters that leave fewer than two eligible cohorts.
  • The rate_gap calculation subtracts the lowest displayed rate from the highest displayed rate.
  • A gap above 20 percentage points triggers the review warning.
  • A smaller gap still triggers a reminder to continue monitoring.

The 20-point condition is an internal demo rule. It is not a legal finding.

Real employment use requires validation plus accommodations. It also requires monitoring plus human oversight.

Before you save, predict whether the default shortlist will stay inside or cross the 20-point review rule.

  • Save app.py.
  • Return to the running app in your browser.
  • Select Python plus SQL in the Required skills control.
  • Select every option in the Locations control.
  • Set Minimum years of experience to 2.
  • Set Shortlist size to 5.

Your chart shows 66.7% for Cohort A plus 16.7% for Cohort B. The warning reports a 50.0 percentage-point demo review signal.

That is the guardrail working: the app turns a hidden cohort gap into a prompt for human review.

Don't see the 50.0-point signal?

  • Check that the four controls match the specified default values.
  • Check that rate_gap uses the maximum rate minus the minimum rate.
  • Check that the review condition uses rate_gap > 20.

Need help tracing a different audit result?

✔️ Awesome, I've got everything!

Great work. Double-check that you saved app.py.

ⓧ I'd like to double check the full code

import pandas as pd
import streamlit as st

CANDIDATES = [
    {"candidate_id": "C001", "candidate": "Ava Chen", "cohort": "Cohort A", "location": "Seattle", "years_experience": 6, "skills": "Python, SQL, Tableau"},
    {"candidate_id": "C002", "candidate": "Ben Ortiz", "cohort": "Cohort A", "location": "Denver", "years_experience": 5, "skills": "Python, SQL, Excel"},
    {"candidate_id": "C003", "candidate": "Cara Singh", "cohort": "Cohort B", "location": "Seattle", "years_experience": 4, "skills": "SQL, Tableau, Excel"},
    {"candidate_id": "C004", "candidate": "Diego Ruiz", "cohort": "Cohort A", "location": "Austin", "years_experience": 7, "skills": "Python, SQL, Statistics"},
    {"candidate_id": "C005", "candidate": "Erin Brooks", "cohort": "Cohort B", "location": "Denver", "years_experience": 3, "skills": "Python, Excel"},
    {"candidate_id": "C006", "candidate": "Farah Ali", "cohort": "Cohort B", "location": "Austin", "years_experience": 5, "skills": "SQL, Statistics, Tableau"},
    {"candidate_id": "C007", "candidate": "Gabe Wilson", "cohort": "Cohort A", "location": "Seattle", "years_experience": 8, "skills": "Python, SQL, Tableau, Statistics"},
    {"candidate_id": "C008", "candidate": "Hana Kim", "cohort": "Cohort B", "location": "Denver", "years_experience": 2, "skills": "Excel, Tableau"},
    {"candidate_id": "C009", "candidate": "Ivan Petrov", "cohort": "Cohort A", "location": "Austin", "years_experience": 4, "skills": "Python, SQL, Excel"},
    {"candidate_id": "C010", "candidate": "Jules Martin", "cohort": "Cohort B", "location": "Seattle", "years_experience": 6, "skills": "Python, Statistics, Excel"},
    {"candidate_id": "C011", "candidate": "Kai Morgan", "cohort": "Cohort A", "location": "Denver", "years_experience": 3, "skills": "SQL, Tableau"},
    {"candidate_id": "C012", "candidate": "Lina Haddad", "cohort": "Cohort B", "location": "Austin", "years_experience": 7, "skills": "Python, SQL, Tableau, Excel"},
]

SKILLS = ["Excel", "Python", "SQL", "Statistics", "Tableau"]
LOCATIONS = sorted({candidate["location"] for candidate in CANDIDATES})
DISPLAY_COLUMNS = ["candidate_id", "candidate", "location", "years_experience", "skills", "score", "reason"]


def score_candidate(candidate, required_skills):
    candidate_skills = {skill.strip() for skill in candidate["skills"].split(",")}
    matched_skills = sorted(set(required_skills) & candidate_skills)
    skill_score = len(matched_skills) / len(required_skills) if required_skills else 1.0
    experience_score = min(candidate["years_experience"] / 8, 1.0)
    total_score = round((0.7 * skill_score + 0.3 * experience_score) * 100, 1)
    match_text = f"{len(matched_skills)}/{len(required_skills)} required skills matched" if required_skills else "No required skills selected"
    reason = f"{match_text}; {candidate['years_experience']} years experience"
    return total_score, reason


def build_ranked_candidates(required_skills, selected_locations, min_experience):
    scored_candidates = []
    for candidate in CANDIDATES:
        location_matches = candidate["location"] in selected_locations
        experience_matches = candidate["years_experience"] >= min_experience
        if location_matches and experience_matches:
            score, reason = score_candidate(candidate, required_skills)
            scored_candidates.append({**candidate, "score": score, "reason": reason})
    if not scored_candidates:
        return pd.DataFrame(columns=DISPLAY_COLUMNS + ["cohort"])
    return pd.DataFrame(scored_candidates).sort_values(by=["score", "candidate_id"], ascending=[False, True], ignore_index=True)


def build_audit(shortlist, ranked_candidates):
    eligible_ids = set(ranked_candidates["candidate_id"])
    selected_ids = set(shortlist["candidate_id"])
    cohorts = sorted({candidate["cohort"] for candidate in CANDIDATES})
    audit_rows = []
    for cohort in cohorts:
        eligible_count = sum(1 for candidate in CANDIDATES if candidate["cohort"] == cohort and candidate["candidate_id"] in eligible_ids)
        selected_count = sum(1 for candidate in CANDIDATES if candidate["cohort"] == cohort and candidate["candidate_id"] in selected_ids)
        if eligible_count > 0:
            selection_rate = round(selected_count / eligible_count * 100, 1)
            audit_rows.append({"cohort": cohort, "eligible": eligible_count, "selected": selected_count, "selection_rate_percent": selection_rate})
    return pd.DataFrame(audit_rows)


st.set_page_config(page_title="Transparent Candidate Ranker", layout="wide")
st.title("Transparent Candidate Ranker")
st.caption("Synthetic data and a rule-based score for learning only. This app must not make employment decisions.")

required_skills = st.multiselect("Required skills", SKILLS, default=["Python", "SQL"])
selected_locations = st.multiselect("Locations", LOCATIONS, default=LOCATIONS)
min_experience = st.slider("Minimum years of experience", 0, 8, 2)
top_n = st.slider("Shortlist size", 1, 8, 5)
st.caption("Demo score: 70% required-skill match + 30% experience, capped at 8 years. Cohort is audit-only and is never used for ranking.")

ranked_candidates = build_ranked_candidates(required_skills, selected_locations, min_experience)
if len(ranked_candidates) == 0:
    st.warning("No candidates match the current filters.")
else:
    shortlist = ranked_candidates.head(top_n)
    average_score = round(sum(shortlist["score"]) / len(shortlist), 1)
    st.metric("Eligible candidates", len(ranked_candidates))
    st.metric("Shortlisted candidates", len(shortlist))
    st.metric("Average shortlist score", average_score)
    st.dataframe(shortlist[DISPLAY_COLUMNS], hide_index=True, width="stretch")
    audit = build_audit(shortlist, ranked_candidates)
    st.bar_chart(audit, x="cohort", y="selection_rate_percent", y_label="Selection rate (%)", color="cohort")
    rates = list(audit["selection_rate_percent"])
    if len(rates) < 2:
        st.warning("The audit needs eligible candidates from at least two cohorts before it can compare selection rates.")
    else:
        rate_gap = round(max(rates) - min(rates), 1)
        if rate_gap > 20:
            st.warning(f"Demo review signal: cohort selection rates differ by {rate_gap} percentage points. This is not a legal finding. A person should review the criteria, data, and outcomes.")
        else:
            st.success(f"The demo selection-rate gap is {rate_gap} percentage points. Continue human review and monitoring before any real use.")

Secret mission

Add a Criteria Weight Simulator

Expose how sensitive the shortlist is to its scoring criteria. You will add a live weight control that reveals changes in candidate rankings and cohort outcomes.

Clean Up Your Resources

Clean Up Your Resources

Your completed Streamlit app runs entirely on your Windows computer. There are no cloud resources or ongoing costs.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The local Streamlit process serving the candidate ranker in your browser.
  • The responsible-candidate-ranker folder containing app.py and requirements.txt.
  • The .venv folder containing the isolated project packages.

Keep everything running

No action needed. Choose this if you are still testing rankings or comparing criteria weights.

  • Keep the Visual Studio Code terminal running so the candidate ranker remains available.
  • Leave the browser tab open to continue changing the shortlist controls.
  • Keep the responsible-candidate-ranker folder to preserve your app and isolated environment.

Your local ranker stays ready for more experiments without creating a financial cost.

Pause - I'll come back to this later

Shut down the running process to free up memory. Your app code and isolated environment remain on your computer.

  • Switch back to the Visual Studio Code terminal from earlier.
  • Stop the Streamlit process by pressing Ctrl+C.

The terminal returns to a prompt. Your browser can no longer connect to the local app.

  • Restart the candidate ranker later from the responsible-candidate-ranker folder by running these commands:
.venv\Scripts\Activate.ps1
python -m streamlit run app.py

What Do These Commands Do?

  • The first command activates the isolated environment containing your pinned packages.
  • The second command starts the candidate ranker from app.py.

You will see the Transparent Candidate Ranker title in the browser. The skill-weight control starts at 70.

App Not Starting Again?

Confirm that the terminal is inside the responsible-candidate-ranker folder before running the commands.

If the environment or app still fails to start, ask for help with the specific terminal output. Help me restart my local candidate ranker.

Delete - I don't want to use this again

Remove the running process and the complete project folder when you no longer need the prototype. This permanently removes your code and isolated environment.

  • Copy any files you want to preserve into a folder outside responsible-candidate-ranker.
  • Switch back to the Visual Studio Code terminal from earlier.
  • Stop the Streamlit process by pressing Ctrl+C.

The terminal returns to a prompt. The local app process is now stopped.

  • Delete the complete responsible-candidate-ranker folder from its parent folder by running these commands:
cd ..
rmdir responsible-candidate-ranker -Recurse -Force

What Do These Commands Remove?

  • The first command moves the terminal into the folder that contains your project.
  • The second command permanently deletes the responsible-candidate-ranker folder.
  • The deletion includes app.py and requirements.txt.
  • The deletion also includes the isolated .venv environment.

The command returns to the parent folder prompt without an error. The project files and local environment are now removed.

Folder Still Present?

Confirm that the Streamlit process stopped before you tried to delete the folder. Check that the deletion command uses the exact folder name responsible-candidate-ranker.

If Windows still prevents deletion, ask for help with the message shown in the terminal. Help me remove the project folder.

Nice Work!

Nice Work!

That's a wrap! Your local candidate-ranking app now produces transparent shortlists from synthetic data. It also checks cohort outcomes while leaving every decision to a person.

You've learned how to:

  • Build a local Streamlit candidate ranker that filters a synthetic candidate pool into an interactive shortlist.
  • Make rule-based scoring inspectable through explicit criteria weights plus candidate-level explanations. Keep cohort data reserved for outcome auditing.
  • Compare cohort selection rates through outcome monitoring. Preserve human oversight with a review signal when the demo rule detects a large gap.
  • Secret Mission: Add an interactive criteria weight simulator that exposes changes across rankings plus cohort outcomes.

Ready to quiz yourself?