Ano woy commited on
Commit
9ab4664
Β·
verified Β·
1 Parent(s): 726a98d

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +32 -0
  2. app.py +194 -0
  3. bias_detector.py +665 -0
  4. requirements.txt +11 -0
Dockerfile ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ RUN apt-get update && apt-get install -y --no-install-recommends \
4
+ build-essential gcc g++ \
5
+ libgomp1 && \
6
+ rm -rf /var/lib/apt/lists/*
7
+
8
+ WORKDIR /app
9
+
10
+ # Install torch CPU separately to avoid index-url conflicts
11
+ RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
12
+
13
+ # Install Qwen2.5-7B compatible transformers + core deps
14
+ RUN pip install --no-cache-dir \
15
+ "transformers>=4.53.0" \
16
+ accelerate \
17
+ gradio \
18
+ "spacy>=3.7.0,<3.8.0"
19
+
20
+ # Install bitsandbytes (4-bit quantisation) and CodeCarbon (sustainability tracking)
21
+ RUN pip install --no-cache-dir \
22
+ "bitsandbytes>=0.43.0" \
23
+ "codecarbon>=2.4.0"
24
+
25
+ RUN python -m spacy download en_core_web_sm
26
+
27
+ COPY bias_detector.py .
28
+ COPY app.py .
29
+
30
+ EXPOSE 7860
31
+
32
+ CMD ["python", "app.py"]
app.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================
2
+ # app.py β€” Gradio interface for HuggingFace Spaces
3
+ # Provides both a web UI and automatic API endpoints
4
+ # ============================================
5
+
6
+ import gradio as gr
7
+ from bias_detector import BiasDetector
8
+
9
+ # Load models once at startup
10
+ bd = BiasDetector()
11
+
12
+ # Session-level sustainability log (accumulated across all requests in one session)
13
+ _session_log: list[dict] = []
14
+
15
+
16
+ def _format_sustainability(s: dict) -> str:
17
+ """Format a sustainability dict into a human-readable string."""
18
+ return (
19
+ f"⚑ Energy: {s['energy_kwh']:.6f} kWh\n"
20
+ f"πŸ’¨ COβ‚‚eq: {s['co2_grams']:.4f} gCOβ‚‚e\n"
21
+ f"πŸ’§ Water: {s['water_liters']:.6f} L"
22
+ )
23
+
24
+
25
+ def _session_totals() -> dict:
26
+ """Sum all sustainability metrics recorded so far this session."""
27
+ return {
28
+ "energy_kwh": sum(r["energy_kwh"] for r in _session_log),
29
+ "co2_grams": sum(r["co2_grams"] for r in _session_log),
30
+ "water_liters": sum(r["water_liters"] for r in _session_log),
31
+ }
32
+
33
+
34
+ def _build_history_table() -> str:
35
+ """Return a plain-text table of all requests so far."""
36
+ if not _session_log:
37
+ return "No requests yet this session."
38
+ lines = [
39
+ f"{'#':<4} {'Type':<20} {'Energy (kWh)':<16} {'COβ‚‚ (gCOβ‚‚e)':<16} {'Water (L)':<12}",
40
+ "-" * 72,
41
+ ]
42
+ for i, r in enumerate(_session_log, 1):
43
+ lines.append(
44
+ f"{i:<4} {r['type']:<20} "
45
+ f"{r['energy_kwh']:<16.6f} "
46
+ f"{r['co2_grams']:<16.4f} "
47
+ f"{r['water_liters']:<12.6f}"
48
+ )
49
+ totals = _session_totals()
50
+ lines.append("-" * 72)
51
+ lines.append(
52
+ f"{'TOTAL':<24} "
53
+ f"{totals['energy_kwh']:<16.6f} "
54
+ f"{totals['co2_grams']:<16.4f} "
55
+ f"{totals['water_liters']:<12.6f}"
56
+ )
57
+ return "\n".join(lines)
58
+
59
+
60
+ # ---- Endpoint functions ----
61
+
62
+ def bias_detection(text):
63
+ """Endpoint: analyze + rewrite a job posting."""
64
+ rewrite = bd.rewrite_job_posting(text)
65
+ result = rewrite["analysis"]
66
+
67
+ summary = (
68
+ f"Overall: {result['overall_label']}\n"
69
+ f"Bias Score: {result['bias_score']}\n"
70
+ f"ML Model: {result['ml_model_result']['label']} "
71
+ f"({round(result['ml_model_result']['score'], 3)})\n"
72
+ f"Masculine-coded words: {result['masculine_count']}\n"
73
+ f"Feminine-coded words: {result['feminine_count']}"
74
+ )
75
+
76
+ flagged_summary = ""
77
+ for f in result["flagged_words"]:
78
+ flagged_summary += f"β€’ \"{f['word']}\" β†’ {f['category']}\n"
79
+ if not flagged_summary:
80
+ flagged_summary = "No gendered words detected by lexicon."
81
+
82
+ # Log sustainability
83
+ s = rewrite["sustainability"]
84
+ _session_log.append({"type": "Job posting rewrite", **s})
85
+
86
+ totals = _session_totals()
87
+ return (
88
+ summary,
89
+ flagged_summary,
90
+ rewrite["fully_rewritten"],
91
+ _format_sustainability(s),
92
+ _build_history_table(),
93
+ _format_sustainability(totals),
94
+ )
95
+
96
+
97
+ def anonymize(text):
98
+ """Endpoint: anonymize a CV/letter."""
99
+ result = bd.anonymize_document(text)
100
+
101
+ # Log sustainability
102
+ s = result["sustainability"]
103
+ _session_log.append({"type": "CV anonymization", **s})
104
+
105
+ totals = _session_totals()
106
+ return (
107
+ result["surface_anonymized"],
108
+ result["fully_anonymized"],
109
+ _format_sustainability(s),
110
+ _build_history_table(),
111
+ _format_sustainability(totals),
112
+ )
113
+
114
+
115
+ def refresh_sustainability():
116
+ """Refresh the sustainability tab manually."""
117
+ totals = _session_totals()
118
+ return _build_history_table(), _format_sustainability(totals)
119
+
120
+
121
+ # ---- Build Gradio UI ----
122
+
123
+ with gr.Blocks(title="Nubias: Gender Bias Detector & CV Anonymizer") as demo:
124
+ gr.Markdown("# 🌍 Nubias: Gender Bias Detector & CV Anonymizer")
125
+ gr.Markdown(
126
+ "Detecting gender bias in job postings and anonymizing CVs/letters "
127
+ "to promote fair hiring practices β€” aligned with **SDG 5: Gender Equality**."
128
+ )
129
+
130
+ # Shared sustainability components (declared here, rendered inside the tab below)
131
+ history_box = gr.Textbox(label="Request History", lines=12, interactive=False, visible=False)
132
+ totals_box = gr.Textbox(label="Session Totals", lines=4, interactive=False, visible=False)
133
+
134
+ with gr.Tab("Job Posting Analyzer"):
135
+ gr.Markdown("### Analyze and rewrite a job posting for gendered language")
136
+ input_posting = gr.Textbox(
137
+ label="Paste job posting here",
138
+ lines=8,
139
+ placeholder="e.g. We are looking for an aggressive self-starter who can dominate the market...",
140
+ )
141
+ btn_analyze = gr.Button("Analyze & Rewrite", variant="primary")
142
+ output_summary = gr.Textbox(label="Bias Analysis", lines=5)
143
+ output_flagged = gr.Textbox(label="Flagged Words", lines=5)
144
+ output_rewrite = gr.Textbox(label="Gender-Neutral Rewrite", lines=8)
145
+ output_sustain_j = gr.Textbox(label="♻️ This Request β€” Sustainability", lines=4)
146
+
147
+ with gr.Tab("CV / Letter Anonymizer"):
148
+ gr.Markdown("### Anonymize a CV, cover letter, or recommendation letter")
149
+ input_cv = gr.Textbox(
150
+ label="Paste document text here",
151
+ lines=8,
152
+ placeholder="e.g. Sarah Johnson (sarah@email.com) is an exceptionally warm leader...",
153
+ )
154
+ btn_anonymize = gr.Button("Anonymize", variant="primary")
155
+ output_surface = gr.Textbox(
156
+ label="Surface Anonymized (names, emails, pronouns, titles removed)", lines=6
157
+ )
158
+ output_full = gr.Textbox(
159
+ label="Fully Anonymized (gendered style words neutralized + LLM pass)", lines=6
160
+ )
161
+ output_sustain_c = gr.Textbox(label="♻️ This Request β€” Sustainability", lines=4)
162
+
163
+ with gr.Tab("♻️ Sustainability"):
164
+ gr.Markdown(
165
+ "### Environmental impact of Nubias this session\n"
166
+ "Estimates are based on measured CPU energy draw (via **CodeCarbon**) "
167
+ "and a data-centre Water Usage Effectiveness (WUE) of **1.6 L / kWh**.\n\n"
168
+ "Carbon intensity uses CodeCarbon's location-aware grid data. "
169
+ "All figures are per-request and cumulative for the current session."
170
+ )
171
+ tab_history = gr.Textbox(label="Request History", lines=12, interactive=False)
172
+ tab_totals = gr.Textbox(label="Session Totals", lines=4, interactive=False)
173
+ btn_refresh = gr.Button("πŸ”„ Refresh", variant="secondary")
174
+ btn_refresh.click(
175
+ refresh_sustainability,
176
+ inputs=[],
177
+ outputs=[tab_history, tab_totals],
178
+ )
179
+
180
+ # Wire action buttons β€” both update the sustainability tab directly
181
+ btn_analyze.click(
182
+ bias_detection,
183
+ inputs=input_posting,
184
+ outputs=[output_summary, output_flagged, output_rewrite,
185
+ output_sustain_j, tab_history, tab_totals],
186
+ )
187
+ btn_anonymize.click(
188
+ anonymize,
189
+ inputs=input_cv,
190
+ outputs=[output_surface, output_full, output_sustain_c,
191
+ tab_history, tab_totals],
192
+ )
193
+
194
+ demo.launch(server_name="0.0.0.0", server_port=7860)
bias_detector.py ADDED
@@ -0,0 +1,665 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================
2
+ # Nubias: Gender Bias Detector & CV Anonymizer
3
+ # ============================================
4
+ # Usage:
5
+ # from bias_detector import BiasDetector
6
+ # bd = BiasDetector()
7
+ # bias_result = bd.analyze_job_posting("We need an aggressive go-getter.")
8
+ # rewrite_result = bd.rewrite_job_posting("We need an aggressive go-getter.")
9
+ # anon_result = bd.anonymize_document("Sarah is a warm and nurturing leader.")
10
+ #
11
+ # Each public method returns a dict that includes a "sustainability" key:
12
+ # {
13
+ # "energy_kwh": float, # estimated kWh consumed by this request
14
+ # "co2_grams": float, # gCO2e for this request
15
+ # "water_liters": float, # estimated water used for cooling
16
+ # }
17
+ # ============================================
18
+
19
+ import re
20
+ import torch
21
+ import spacy
22
+ from collections import Counter
23
+ from transformers import (
24
+ pipeline,
25
+ AutoTokenizer,
26
+ AutoModelForCausalLM,
27
+ BitsAndBytesConfig,
28
+ )
29
+ from codecarbon import EmissionsTracker
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Water Usage Effectiveness constant.
34
+ # Industry average for hyperscale data centres β‰ˆ 1.6 L / kWh of IT load.
35
+ # Source: Mytton (2021), "Hiding greenhouse gas emissions in the cloud".
36
+ # ---------------------------------------------------------------------------
37
+ _WUE_LITERS_PER_KWH = 1.6
38
+
39
+
40
+ def _build_sustainability(tracker: EmissionsTracker) -> dict:
41
+ """
42
+ Stop the tracker and convert its output into the three metrics we report.
43
+ CodeCarbon gives energy in kWh and emissions in kg CO2e.
44
+ """
45
+ tracker.stop()
46
+ energy_kwh = tracker.final_emissions_data.energy_consumed # kWh
47
+ co2_kg = tracker.final_emissions_data.emissions # kg CO2e
48
+ co2_grams = co2_kg * 1000
49
+ water_liters = energy_kwh * _WUE_LITERS_PER_KWH
50
+ return {
51
+ "energy_kwh": round(energy_kwh, 6),
52
+ "co2_grams": round(co2_grams, 4),
53
+ "water_liters": round(water_liters, 6),
54
+ }
55
+
56
+
57
+ class BiasDetector:
58
+ def __init__(self, llm_model_name: str = "Qwen/Qwen2.5-7B-Instruct"):
59
+ """
60
+ Initialize all models. Call once at startup, reuse for every request.
61
+
62
+ Args:
63
+ llm_model_name: HuggingFace instruction-tuned model for rewriting
64
+ and CV anonymization. Defaults to Qwen2.5-7B-Instruct
65
+ loaded in 4-bit to fit on CPU-only Spaces (~4.5 GB RAM).
66
+ """
67
+ print("Loading bias detection model...")
68
+ self.classifier = pipeline(
69
+ "text-classification",
70
+ model="valurank/distilroberta-bias",
71
+ device=0 if torch.cuda.is_available() else -1,
72
+ )
73
+
74
+ print("Loading spaCy NER model...")
75
+ self.nlp = spacy.load("en_core_web_sm")
76
+
77
+ print(f"Loading {llm_model_name} in 4-bit quantisation...")
78
+ bnb_config = BitsAndBytesConfig(
79
+ load_in_4bit=True,
80
+ bnb_4bit_compute_dtype=torch.float16,
81
+ bnb_4bit_use_double_quant=True,
82
+ bnb_4bit_quant_type="nf4",
83
+ )
84
+ self.tokenizer = AutoTokenizer.from_pretrained(llm_model_name)
85
+ self.llm = AutoModelForCausalLM.from_pretrained(
86
+ llm_model_name,
87
+ quantization_config=bnb_config,
88
+ device_map="auto",
89
+ )
90
+
91
+ print("All models loaded successfully!")
92
+
93
+ # ----- Detection Lexicon (stem-based) -----
94
+
95
+ self.detection_lexicon = {
96
+ "masculine": [
97
+ "active", "adventurous", "aggress", "ambitio", "analy", "assert",
98
+ "autonom", "challeng", "compet", "confident", "courag", "decisive",
99
+ "determin", "dominant", "force", "independen", "individual",
100
+ "intellect", "lead", "logic", "objective", "outspoken", "persist",
101
+ "self-confiden", "self-relian", "self-sufficien", "superior",
102
+ "rockstar", "ninja", "manpower", "chairman", "he/him",
103
+ "fearless", "strong-willed", "ruthless", "hustle", "crush",
104
+ "conquer", "warrior", "battle-tested", "go-getter",
105
+ ],
106
+ "feminine": [
107
+ "affectionate", "cheer", "commit", "communal", "compassion",
108
+ "connect", "considerate", "cooperat", "depend", "empath",
109
+ "gentle", "honest", "interpersonal", "interdependen", "kind",
110
+ "loyal", "nurtur", "pleasant", "polite", "respon", "sensitiv",
111
+ "support", "sympath", "tender", "trust", "understand", "warm",
112
+ "yield", "collaborative", "caring", "welcoming", "friendly",
113
+ "helpful", "people-oriented", "team-oriented", "encouraging",
114
+ "agreeable", "devoted", "gracious", "modest",
115
+ ],
116
+ }
117
+
118
+ # ----- Word-level replacements for job posting rewriting -----
119
+
120
+ self.word_replacements = {
121
+ "aggressive": "proactive",
122
+ "aggressively": "proactively",
123
+ "ambitious": "motivated",
124
+ "dominant": "experienced",
125
+ "dominate": "excel in",
126
+ "manpower": "workforce",
127
+ "chairman": "chairperson",
128
+ "ninja": "specialist",
129
+ "rockstar": "top performer",
130
+ "fearless": "confident",
131
+ "ruthless": "results-driven",
132
+ "crush": "achieve",
133
+ "conquer": "succeed in",
134
+ "warrior": "professional",
135
+ "hustle": "dedication",
136
+ "go-getter": "self-starter",
137
+ "nurturing": "supportive",
138
+ "warm": "approachable",
139
+ "sympathetic": "understanding",
140
+ "gentle": "thoughtful",
141
+ "tender": "considerate",
142
+ "yielding": "flexible",
143
+ "caring": "attentive",
144
+ "agreeable": "cooperative",
145
+ "devoted": "dedicated",
146
+ "modest": "professional",
147
+ }
148
+
149
+ # ----- Gendered style word replacements for CV anonymization -----
150
+
151
+ self.style_replacements = {
152
+ # Feminine-coded β†’ neutral
153
+ "warm": "effective",
154
+ "nurturing": "supportive",
155
+ "nurture": "develop",
156
+ "nurtured": "developed",
157
+ "gentle": "measured",
158
+ "caring": "attentive",
159
+ "compassionate": "considerate",
160
+ "compassion": "consideration",
161
+ "sympathetic": "understanding",
162
+ "sympathy": "understanding",
163
+ "affectionate": "personable",
164
+ "tender": "thoughtful",
165
+ "devoted": "dedicated",
166
+ "gracious": "professional",
167
+ "motherly": "mentoring",
168
+ "sweet": "pleasant",
169
+ "yielding": "flexible",
170
+ "submissive": "cooperative",
171
+ "emotional": "perceptive",
172
+ "cheerful": "positive",
173
+ "pleasant": "professional",
174
+ "polite": "courteous",
175
+ "agreeable": "cooperative",
176
+ "modest": "understated",
177
+ "friendly": "collegial",
178
+ "welcoming": "inclusive",
179
+ # Masculine-coded β†’ neutral
180
+ "aggressive": "proactive",
181
+ "aggressively": "proactively",
182
+ "dominant": "strong",
183
+ "dominated": "excelled in",
184
+ "forceful": "effective",
185
+ "forcefully": "effectively",
186
+ "ambitious": "motivated",
187
+ "fierce": "determined",
188
+ "fiercely": "with determination",
189
+ "ruthless": "results-oriented",
190
+ "bold": "decisive",
191
+ "boldly": "decisively",
192
+ "fearless": "confident",
193
+ "fearlessly": "confidently",
194
+ "commanding": "authoritative",
195
+ "headstrong": "resolute",
196
+ }
197
+
198
+ # ----- System prompts -----
199
+
200
+ self.JOB_REWRITE_PROMPT = """You are a job posting editor. Your ONLY job is to rewrite job postings to be gender-neutral.
201
+
202
+ Rules:
203
+ 1. Replace masculine-coded words (aggressive, dominant, fearless, ninja, rockstar) with neutral alternatives
204
+ 2. Replace feminine-coded words (nurturing, warm, sympathetic) with neutral alternatives
205
+ 3. Keep ALL job requirements, qualifications, and responsibilities intact
206
+ 4. Keep the same professional tone and structure
207
+ 5. Do NOT add or remove job requirements
208
+ 6. Output ONLY the rewritten job posting, nothing else
209
+
210
+ Example:
211
+ Input: "We need an aggressive go-getter who can crush the competition and dominate the market."
212
+ Output: "We need a motivated professional who can deliver strong results and excel in the market."
213
+ """
214
+
215
+ self.CV_ANONYMIZE_PROMPT = """You are a document anonymizer specializing in gender-neutral language.
216
+ You will receive a CV or letter that has already been partially anonymized (names replaced with [CANDIDATE]/[PERSON_N], emails with [EMAIL], pronouns neutralized).
217
+ Your job is to do a final pass to catch any remaining gendered language the lexicon missed.
218
+
219
+ Rules:
220
+ 1. Replace any remaining gendered pronouns (he, she, his, her, him, himself, herself) with they/their/them/themselves
221
+ 2. Replace any remaining gendered titles or nouns with neutral equivalents
222
+ 3. Replace any remaining gendered descriptors with neutral alternatives
223
+ 4. Do NOT change [CANDIDATE], [PERSON_2], [PERSON_3], [EMAIL] placeholders β€” keep them exactly as-is
224
+ 5. Do NOT add, invent, or remove any factual content
225
+ 6. Do NOT add commentary or explanation
226
+ 7. Output ONLY the anonymized text, nothing else
227
+ """
228
+
229
+ # =============================================
230
+ # HELPER: LLM generation
231
+ # =============================================
232
+
233
+ def _generate(self, system_prompt: str, user_message: str, max_extra_tokens: int = 300) -> str:
234
+ """Run a single LLM inference call. Used by both rewrite and anonymize."""
235
+ messages = [
236
+ {"role": "system", "content": system_prompt},
237
+ {"role": "user", "content": user_message},
238
+ ]
239
+
240
+ input_text = self.tokenizer.apply_chat_template(
241
+ messages, tokenize=False, add_generation_prompt=True
242
+ )
243
+ inputs = self.tokenizer(input_text, return_tensors="pt").to(self.llm.device)
244
+
245
+ with torch.no_grad():
246
+ outputs = self.llm.generate(
247
+ **inputs,
248
+ max_new_tokens=len(inputs["input_ids"][0]) + max_extra_tokens,
249
+ temperature=0.3,
250
+ do_sample=True,
251
+ top_p=0.9,
252
+ repetition_penalty=1.2,
253
+ )
254
+
255
+ generated = outputs[0][inputs["input_ids"].shape[1]:]
256
+ result = self.tokenizer.decode(generated, skip_special_tokens=True).strip()
257
+
258
+ # Strip LLM commentary that sometimes follows the output
259
+ cutoff_markers = [
260
+ "\nI made", "\nI changed", "\nI replaced", "\nI also",
261
+ "\nNote:", "\nChanges:", "\nExplanation:",
262
+ "\nHere's", "\nThe revised", "\nThis version",
263
+ "\nIn this", "\nBy replacing",
264
+ ]
265
+ for marker in cutoff_markers:
266
+ if marker in result:
267
+ result = result[:result.index(marker)].strip()
268
+
269
+ # Strip wrapping quotes
270
+ if result.startswith('"') and result.endswith('"'):
271
+ result = result[1:-1].strip()
272
+
273
+ return result
274
+
275
+ # =============================================
276
+ # HELPER: Lexicon-based style word replacement
277
+ # =============================================
278
+
279
+ def _replace_style_words(self, text: str, replacements_dict: dict) -> str:
280
+ """Replace gendered style words using a dictionary. Preserves capitalization."""
281
+ result = text
282
+ for old_word, new_word in replacements_dict.items():
283
+ pattern = re.compile(r"\b" + re.escape(old_word) + r"\b", re.IGNORECASE)
284
+
285
+ def replace_keep_case(match, replacement=new_word):
286
+ original = match.group(0)
287
+ if original[0].isupper():
288
+ return replacement[0].upper() + replacement[1:]
289
+ return replacement
290
+
291
+ result = pattern.sub(replace_keep_case, result)
292
+ return result
293
+
294
+ # =============================================
295
+ # PHASE 1A: BIAS DETECTION IN JOB POSTINGS
296
+ # =============================================
297
+
298
+ def _run_bias_analysis(self, text: str) -> dict:
299
+ """
300
+ Core bias detection logic (no tracker). Called internally by both
301
+ analyze_job_posting() and rewrite_job_posting() to avoid nested trackers.
302
+ """
303
+ # 1. ML model score
304
+ ml_result = self.classifier(text)[0]
305
+
306
+ # 2. Lexicon scan using stem matching
307
+ flagged_words = []
308
+ words_in_text = text.lower().split()
309
+
310
+ for category, stems in self.detection_lexicon.items():
311
+ for stem in stems:
312
+ for word in words_in_text:
313
+ if word.startswith(stem) or stem in word:
314
+ flagged_words.append({
315
+ "word": word.strip(".,;:!?()\"'"),
316
+ "stem": stem,
317
+ "category": category + "-coded",
318
+ })
319
+
320
+ # Remove duplicates
321
+ seen = set()
322
+ unique_flags = []
323
+ for f in flagged_words:
324
+ if f["word"] not in seen:
325
+ seen.add(f["word"])
326
+ unique_flags.append(f)
327
+
328
+ # 3. Combined bias score
329
+ ml_bias_score = (
330
+ 1 - ml_result["score"]
331
+ if ml_result["label"] == "NEUTRAL"
332
+ else ml_result["score"]
333
+ )
334
+ lexicon_boost = min(len(unique_flags) * 0.15, 0.5)
335
+ combined_score = min(ml_bias_score + lexicon_boost, 1.0)
336
+
337
+ # 4. Overall label
338
+ overall_label = "BIASED" if combined_score >= 0.5 else "NEUTRAL"
339
+
340
+ return {
341
+ "overall_label": overall_label,
342
+ "bias_score": round(combined_score, 3),
343
+ "ml_model_result": ml_result,
344
+ "flagged_words": unique_flags,
345
+ "masculine_count": sum(1 for f in unique_flags if f["category"] == "masculine-coded"),
346
+ "feminine_count": sum(1 for f in unique_flags if f["category"] == "feminine-coded"),
347
+ }
348
+
349
+ def analyze_job_posting(self, text: str) -> dict:
350
+ """
351
+ Hybrid bias detector: ML model + lexicon scan.
352
+ Tracks energy, CO2, and water consumption for the full request.
353
+
354
+ Returns dict with: overall_label, bias_score, ml_model_result,
355
+ flagged_words, masculine_count, feminine_count, sustainability.
356
+ """
357
+ tracker = EmissionsTracker(
358
+ project_name="nubias_bias_detection",
359
+ log_level="error",
360
+ save_to_file=False,
361
+ )
362
+ tracker.start()
363
+
364
+ result = self._run_bias_analysis(text)
365
+ sustainability = _build_sustainability(tracker)
366
+
367
+ return {**result, "sustainability": sustainability}
368
+
369
+ # =============================================
370
+ # PHASE 1B: JOB POSTING REWRITER
371
+ # =============================================
372
+
373
+ def rewrite_job_posting(self, text: str) -> dict:
374
+ """
375
+ Rewrites a job posting to remove gendered language.
376
+ Combines lexicon-based replacements with Qwen2.5-7B rewriting.
377
+ Sustainability covers the full pipeline (classifier + LLM).
378
+
379
+ Returns dict with: original, analysis, lexicon_fixed,
380
+ fully_rewritten, sustainability.
381
+ """
382
+ tracker = EmissionsTracker(
383
+ project_name="nubias_job_rewrite",
384
+ log_level="error",
385
+ save_to_file=False,
386
+ )
387
+ tracker.start()
388
+
389
+ # 1. Bias detection (use private method to avoid nested trackers)
390
+ analysis = self._run_bias_analysis(text)
391
+
392
+ # 2. Lexicon-based quick fix
393
+ lexicon_fixed = self._replace_style_words(text, self.word_replacements)
394
+
395
+ # 3. LLM rewrite for deeper neutralization
396
+ rewritten = self._generate(
397
+ self.JOB_REWRITE_PROMPT,
398
+ f"Rewrite this job posting to be gender-neutral. Output ONLY the rewritten text:\n\n{lexicon_fixed}",
399
+ max_extra_tokens=300,
400
+ )
401
+
402
+ # Fallback if LLM output looks broken
403
+ if len(rewritten) < 10 or len(rewritten) > len(text) * 3:
404
+ rewritten = lexicon_fixed
405
+
406
+ sustainability = _build_sustainability(tracker)
407
+
408
+ return {
409
+ "original": text,
410
+ "analysis": analysis,
411
+ "lexicon_fixed": lexicon_fixed,
412
+ "fully_rewritten": rewritten,
413
+ "sustainability": sustainability,
414
+ }
415
+
416
+ # =============================================
417
+ # PHASE 2: CV / LETTER GENDER ANONYMIZER
418
+ # =============================================
419
+
420
+ def _surface_anonymize(self, text: str) -> str:
421
+ """
422
+ Step A: Replace emails with [EMAIL].
423
+ Step B: spaCy NER β†’ frequency-based person labelling.
424
+ Most-mentioned person β†’ [CANDIDATE].
425
+ Others β†’ [PERSON_2], [PERSON_3], … in order of first appearance.
426
+ Step C: Rule-based pronoun / title / gendered-noun replacement.
427
+ """
428
+
429
+ # --- Step A: Email addresses ---
430
+ anonymized = re.sub(
431
+ r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}",
432
+ "[EMAIL]",
433
+ text,
434
+ )
435
+
436
+ # --- Step B: Person names via spaCy NER ---
437
+ doc = self.nlp(anonymized)
438
+
439
+ # Collect all PERSON spans and count mention frequency per canonical name
440
+ # (use the first token as a rough canonical key to handle "Sarah" vs "Sarah Johnson")
441
+ person_spans = [
442
+ (ent.start_char, ent.end_char, ent.text)
443
+ for ent in doc.ents
444
+ if ent.label_ == "PERSON"
445
+ ]
446
+
447
+ # Count frequency by normalised name (lower-case first token)
448
+ name_freq: Counter = Counter()
449
+ first_seen: dict = {} # normalised_name β†’ first start_char
450
+ for start, end, name in person_spans:
451
+ key = name.strip().lower().split()[0]
452
+ name_freq[key] += 1
453
+ if key not in first_seen:
454
+ first_seen[key] = start
455
+
456
+ if name_freq:
457
+ # Most-frequent name is the candidate
458
+ candidate_key = name_freq.most_common(1)[0][0]
459
+
460
+ # Remaining names ordered by first appearance
461
+ other_keys = sorted(
462
+ [k for k in name_freq if k != candidate_key],
463
+ key=lambda k: first_seen[k],
464
+ )
465
+ label_map = {candidate_key: "[CANDIDATE]"}
466
+ for i, k in enumerate(other_keys, start=2):
467
+ label_map[k] = f"[PERSON_{i}]"
468
+
469
+ # Replace spans in reverse order to preserve char offsets
470
+ for start, end, name in reversed(person_spans):
471
+ key = name.strip().lower().split()[0]
472
+ label = label_map.get(key, "[PERSON]")
473
+ anonymized = anonymized[:start] + label + anonymized[end:]
474
+
475
+ # --- Step C: Pronouns, titles, gendered nouns ---
476
+ replacements = [
477
+ # Pronoun + verb agreement (she/he β†’ they)
478
+ (r"\b[Ss]he has\b", "They have"),
479
+ (r"\b[Ss]he is\b", "They are"),
480
+ (r"\b[Ss]he was\b", "They were"),
481
+ (r"\b[Ss]he works\b", "They work"),
482
+ (r"\b[Ss]he often\b", "They often"),
483
+ (r"\b[Ss]he also\b", "They also"),
484
+ (r"\b[Ss]he always\b", "They always"),
485
+ (r"\b[Ss]he then\b", "They then"),
486
+ (r"\b[Ss]he quickly\b", "They quickly"),
487
+ (r"\b[Ss]he approached\b", "They approached"),
488
+ (r"\b[Ss]he independently\b", "They independently"),
489
+ (r"\b[Hh]e has\b", "They have"),
490
+ (r"\b[Hh]e is\b", "They are"),
491
+ (r"\b[Hh]e was\b", "They were"),
492
+ (r"\b[Hh]e works\b", "They work"),
493
+ (r"\b[Hh]e often\b", "They often"),
494
+ (r"\b[Hh]e also\b", "They also"),
495
+ (r"\b[Hh]e always\b", "They always"),
496
+ (r"\b[Hh]e then\b", "They then"),
497
+ (r"\b[Hh]e quickly\b", "They quickly"),
498
+ # Object pronoun: verb + her/him β†’ verb + them
499
+ (r"\benable [Hh]er\b", "enable them"),
500
+ (r"\benable [Hh]im\b", "enable them"),
501
+ (r"\bmade [Hh]er\b", "made them"),
502
+ (r"\bmade [Hh]im\b", "made them"),
503
+ (r"\bmake [Hh]er\b", "make them"),
504
+ (r"\bmake [Hh]im\b", "make them"),
505
+ (r"\bhelped [Hh]er\b", "helped them"),
506
+ (r"\bhelped [Hh]im\b", "helped them"),
507
+ (r"\ballow [Hh]er\b", "allow them"),
508
+ (r"\ballow [Hh]im\b", "allow them"),
509
+ (r"\bgave [Hh]er\b", "gave them"),
510
+ (r"\bgave [Hh]im\b", "gave them"),
511
+ (r"\btold [Hh]er\b", "told them"),
512
+ (r"\btold [Hh]im\b", "told them"),
513
+ (r"\basked [Hh]er\b", "asked them"),
514
+ (r"\basked [Hh]im\b", "asked them"),
515
+ (r"\bshowed [Hh]er\b", "showed them"),
516
+ (r"\bshowed [Hh]im\b", "showed them"),
517
+ (r"\btaught [Hh]er\b", "taught them"),
518
+ (r"\btaught [Hh]im\b", "taught them"),
519
+ (r"\boffered [Hh]er\b", "offered them"),
520
+ (r"\boffered [Hh]im\b", "offered them"),
521
+ (r"\bsent [Hh]er\b", "sent them"),
522
+ (r"\bsent [Hh]im\b", "sent them"),
523
+ (r"\bserve [Hh]er\b", "serve them"),
524
+ (r"\bserve [Hh]im\b", "serve them"),
525
+ # Possessive before nouns
526
+ (r"\b[Hh]er(?=\s+\w)", "their"),
527
+ (r"\b[Hh]is(?=\s+\w)", "their"),
528
+ # Standalone pronouns
529
+ (r"\bShe\b", "They"),
530
+ (r"\bshe\b", "they"),
531
+ (r"\bHe\b", "They"),
532
+ (r"\bhe\b", "they"),
533
+ (r"\b[Hh]im\b", "them"),
534
+ (r"\b[Hh]erself\b", "themselves"),
535
+ (r"\b[Hh]imself\b", "themselves"),
536
+ # Titles (remove)
537
+ (r"\bMrs?\.\s*", ""),
538
+ (r"\bMs\.\s*", ""),
539
+ (r"\bMiss\s+", ""),
540
+ # Gendered nouns
541
+ (r"\b[Hh]usband\b", "spouse"),
542
+ (r"\b[Ww]ife\b", "spouse"),
543
+ (r"\b[Mm]other\b", "parent"),
544
+ (r"\b[Ff]ather\b", "parent"),
545
+ (r"\b[Ss]on\b", "child"),
546
+ (r"\b[Dd]aughter\b", "child"),
547
+ (r"\b[Bb]rother\b", "sibling"),
548
+ (r"\b[Ss]ister\b", "sibling"),
549
+ (r"\b[Bb]oyfriend\b", "partner"),
550
+ (r"\b[Gg]irlfriend\b", "partner"),
551
+ (r"\b[Ss]pokesman\b", "spokesperson"),
552
+ (r"\b[Ss]pokeswoman\b","spokesperson"),
553
+ (r"\b[Cc]hairman\b", "chairperson"),
554
+ (r"\b[Cc]hairwoman\b", "chairperson"),
555
+ (r"\b[Mm]anpower\b", "workforce"),
556
+ (r"\b[Gg]irl\b", "person"),
557
+ (r"\b[Bb]oy\b", "person"),
558
+ (r"\b[Ww]oman\b", "person"),
559
+ (r"\b[Ww]omen\b", "people"),
560
+ (r"\b[Mm]an\b", "person"),
561
+ (r"\b[Mm]en\b", "people"),
562
+ (r"\b[Ll]ady\b", "person"),
563
+ (r"\b[Ll]adies\b", "people"),
564
+ (r"\b[Gg]entleman\b", "person"),
565
+ (r"\b[Gg]entlemen\b", "people"),
566
+ (r"\b[Ff]emale\b", "person"),
567
+ (r"\b[Ff]emales\b", "people"),
568
+ (r"\b[Mm]ale\b", "person"),
569
+ (r"\b[Mm]ales\b", "people"),
570
+ ]
571
+
572
+ for pattern, replacement in replacements:
573
+ anonymized = re.sub(pattern, replacement, anonymized)
574
+
575
+ # Clean up artefacts
576
+ anonymized = re.sub(r"\s{2,}", " ", anonymized)
577
+ anonymized = re.sub(r"\.\s*\.", ".", anonymized)
578
+ return anonymized.strip()
579
+
580
+ def anonymize_document(self, text: str) -> dict:
581
+ """
582
+ Full anonymization pipeline:
583
+ Step A: Email regex β†’ [EMAIL]
584
+ Step B: spaCy NER β†’ frequency-based [CANDIDATE] / [PERSON_N] labels
585
+ Step C: Rule-based β†’ replace pronouns, titles, gendered nouns
586
+ Step D: Lexicon-based β†’ replace gendered style words
587
+ Step E: LLM final pass β†’ catch anything the lexicon missed
588
+ Sustainability covers the full pipeline.
589
+
590
+ Returns dict with: original, surface_anonymized,
591
+ fully_anonymized, sustainability.
592
+ """
593
+ tracker = EmissionsTracker(
594
+ project_name="nubias_cv_anonymize",
595
+ log_level="error",
596
+ save_to_file=False,
597
+ )
598
+ tracker.start()
599
+
600
+ # Steps A–C: surface anonymization (emails, names, pronouns, titles)
601
+ surface_result = self._surface_anonymize(text)
602
+
603
+ # Step D: Style word neutralization
604
+ lexicon_result = self._replace_style_words(surface_result, self.style_replacements)
605
+
606
+ # Step E: LLM final pass
607
+ llm_result = self._generate(
608
+ self.CV_ANONYMIZE_PROMPT,
609
+ f"Perform a final gender-neutralization pass on this text. Output ONLY the result:\n\n{lexicon_result}",
610
+ max_extra_tokens=400,
611
+ )
612
+
613
+ # Fallback if LLM output looks broken
614
+ fully_anonymized = (
615
+ llm_result
616
+ if len(llm_result) >= len(lexicon_result) * 0.5
617
+ else lexicon_result
618
+ )
619
+
620
+ sustainability = _build_sustainability(tracker)
621
+
622
+ return {
623
+ "original": text,
624
+ "surface_anonymized": surface_result,
625
+ "fully_anonymized": fully_anonymized,
626
+ "sustainability": sustainability,
627
+ }
628
+
629
+
630
+ # =============================================
631
+ # Quick test (run this file directly to verify)
632
+ # =============================================
633
+ if __name__ == "__main__":
634
+ bd = BiasDetector()
635
+
636
+ print("=" * 50)
637
+ print("TEST 1: Job Posting Bias Detection")
638
+ print("=" * 50)
639
+ test_posting = "We need an aggressive go-getter who can dominate the competition."
640
+ result = bd.analyze_job_posting(test_posting)
641
+ print(f"Label: {result['overall_label']}")
642
+ print(f"Score: {result['bias_score']}")
643
+ print(f"Flagged: {[f['word'] for f in result['flagged_words']]}")
644
+ print(f"Sustainability: {result['sustainability']}")
645
+
646
+ print("\n" + "=" * 50)
647
+ print("TEST 2: Job Posting Rewrite")
648
+ print("=" * 50)
649
+ result = bd.rewrite_job_posting(test_posting)
650
+ print(f"Original: {result['original']}")
651
+ print(f"Lexicon: {result['lexicon_fixed']}")
652
+ print(f"Rewritten: {result['fully_rewritten']}")
653
+ print(f"Sustainability: {result['sustainability']}")
654
+
655
+ print("\n" + "=" * 50)
656
+ print("TEST 3: CV Anonymization")
657
+ print("=" * 50)
658
+ test_cv = """Dr. Sarah Johnson (sarah.johnson@email.com) is an exceptionally warm and nurturing leader.
659
+ She has always been deeply compassionate and sympathetic toward her colleagues.
660
+ Her husband mentioned she is also a devoted mother who balances work and family gracefully.
661
+ I, Prof. Michael Davies, am delighted to recommend her for this position."""
662
+ result = bd.anonymize_document(test_cv)
663
+ print(f"SURFACE:\n{result['surface_anonymized']}\n")
664
+ print(f"FULLY ANONYMIZED:\n{result['fully_anonymized']}")
665
+ print(f"Sustainability: {result['sustainability']}")
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ transformers>=4.53.0
2
+ torch>=2.0.0
3
+ accelerate
4
+ gradio
5
+ numpy<2.0.0
6
+ spacy>=3.7.0,<3.8.0
7
+ thinc>=8.2.0,<8.3.0
8
+ blis>=0.7.9,<1.1.0
9
+ en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.7.1/en_core_web_sm-3.7.1-py3-none-any.whl
10
+ bitsandbytes>=0.43.0
11
+ codecarbon>=2.4.0