Ano woy commited on
Commit
462cd52
·
verified ·
1 Parent(s): 2ead69c

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile +5 -3
  2. bias_detector.py +37 -62
  3. requirements.txt +5 -1
Dockerfile CHANGED
@@ -9,17 +9,19 @@ WORKDIR /app
9
  # Install torch CPU separately to avoid index-url conflicts
10
  RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
11
 
12
- # Install all other dependencies
13
  RUN pip install --no-cache-dir \
14
- "transformers>=4.53.0,<5.0.0" \
15
  accelerate \
16
  gradio \
17
- "numpy<2.0.0"
18
 
19
  # Install CodeCarbon (sustainability tracking)
20
  RUN pip install --no-cache-dir \
21
  "codecarbon>=2.4.0"
22
 
 
 
23
  COPY bias_detector.py .
24
  COPY app.py .
25
 
 
9
  # Install torch CPU separately to avoid index-url conflicts
10
  RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
11
 
12
+ # Install Qwen2.5-7B compatible transformers + core deps
13
  RUN pip install --no-cache-dir \
14
+ "transformers>=4.53.0" \
15
  accelerate \
16
  gradio \
17
+ "spacy>=3.7.0,<3.8.0"
18
 
19
  # Install CodeCarbon (sustainability tracking)
20
  RUN pip install --no-cache-dir \
21
  "codecarbon>=2.4.0"
22
 
23
+ RUN python -m spacy download en_core_web_sm
24
+
25
  COPY bias_detector.py .
26
  COPY app.py .
27
 
bias_detector.py CHANGED
@@ -18,6 +18,7 @@
18
 
19
  import re
20
  import torch
 
21
  from collections import Counter
22
  from transformers import (
23
  pipeline,
@@ -58,8 +59,9 @@ class BiasDetector:
58
  Initialize all models. Call once at startup, reuse for every request.
59
 
60
  Args:
61
- llm_model_name: HuggingFace instruction-tuned model for rewriting.
62
- Defaults to Qwen2.5-1.5B-Instruct (float16, CPU).
 
63
  """
64
  print("Loading bias detection model...")
65
  self.classifier = pipeline(
@@ -68,13 +70,8 @@ class BiasDetector:
68
  device=0 if torch.cuda.is_available() else -1,
69
  )
70
 
71
- print("Loading transformer NER model...")
72
- self.ner = pipeline(
73
- "ner",
74
- model="dslim/bert-base-NER",
75
- aggregation_strategy="simple",
76
- device=0 if torch.cuda.is_available() else -1,
77
- )
78
 
79
  print(f"Loading {llm_model_name}...")
80
  self.tokenizer = AutoTokenizer.from_pretrained(llm_model_name)
@@ -421,7 +418,7 @@ Output: "They pursue excellence and they work hard. They have delivered results.
421
  def _surface_anonymize(self, text: str) -> str:
422
  """
423
  Step A: Replace emails with [EMAIL].
424
- Step B: Transformer NER (dslim/bert-base-NER) → frequency-based person labelling.
425
  Most-mentioned person → [CANDIDATE].
426
  Others → [PERSON_2], [PERSON_3], … in order of first appearance.
427
  Step C: Rule-based pronoun / title / gendered-noun replacement.
@@ -445,22 +442,24 @@ Output: "They pursue excellence and they work hard. They have delivered results.
445
  text,
446
  )
447
 
448
- # --- Step B: Person names via transformer NER (dslim/bert-base-NER) ---
449
- # Returns entity_group PER/ORG/LOC/MISC — we only keep PER.
450
- # Unlike spaCy en_core_web_sm, this model correctly distinguishes
451
- # company names (ORG) and locations (LOC) from person names (PER),
452
- # eliminating false positives like "Luminary Analytics" or "Machine Learning".
453
- ner_results = self.ner(anonymized)
454
- person_spans = []
455
- for ent in ner_results:
456
- if ent["entity_group"] != "PER":
457
- continue
458
- # Clean BERT subword artifacts (## prefixes from tokenizer)
459
- word = ent["word"].replace("##", "").strip()
460
- # Skip if too short to be a real name token (avoids partial matches)
461
- if len(word) < 3:
462
- continue
463
- person_spans.append((ent["start"], ent["end"], word))
 
 
464
 
465
  # Count frequency by normalised name (lower-case first token)
466
  name_freq: Counter = Counter()
@@ -471,7 +470,7 @@ Output: "They pursue excellence and they work hard. They have delivered results.
471
  if key not in first_seen:
472
  first_seen[key] = start
473
 
474
- # Seed name_freq with email-derived names not already found by NER
475
  for first, last, full in email_names:
476
  key = first.lower()
477
  if key not in name_freq:
@@ -492,60 +491,36 @@ Output: "They pursue excellence and they work hard. They have delivered results.
492
  for i, k in enumerate(other_keys, start=2):
493
  label_map[k] = f"[PERSON_{i}]"
494
 
495
- # Extend person spans: if the token immediately after a PER span
496
- # is a capitalised word not in common vocab, treat it as a surname
497
- # e.g. NER finds "Marcus" but misses "Obi" → extend to "Marcus Obi"
498
- extended_spans = []
499
- for start, end, name in person_spans:
500
  key = name.strip().lower().split()[0]
501
  label = label_map.get(key, "[PERSON]")
502
- # Check if next token after span is a capitalised word (surname candidate)
503
- rest = anonymized[end:]
504
- surname_match = re.match(r"^\s+([A-Z][A-Za-z]{1,20})\b", rest)
505
- if surname_match:
506
- candidate_surname = surname_match.group(1)
507
- # Only extend if it's not a common non-name word
508
- NON_NAMES = {"The", "This", "That", "Their", "They", "He", "She",
509
- "His", "Her", "During", "In", "At", "For", "And",
510
- "But", "With", "From", "To", "Of", "On", "By"}
511
- if candidate_surname not in NON_NAMES:
512
- new_end = end + len(surname_match.group(0))
513
- new_name = anonymized[start:new_end].strip()
514
- extended_spans.append((start, new_end, new_name, label))
515
- # Also register the surname token in full_name_label later
516
- continue
517
- extended_spans.append((start, end, name, label))
518
-
519
- # Replace NER spans in reverse order to preserve char offsets
520
- for start, end, name, label in reversed(extended_spans):
521
  anonymized = anonymized[:start] + label + anonymized[end:]
522
 
523
  # Second pass: regex sweep for any remaining occurrences of known names
524
  # (catches names spaCy missed because they were next to org/title context).
525
  # Sort longest-first to avoid partial replacements.
526
  full_name_label: dict = {}
527
- for start, end, name, label in extended_spans:
 
 
528
  full_name_label[name.strip()] = label
529
- # Also register individual tokens (first name, last name separately)
530
- for token in name.strip().split():
531
- if len(token) >= 4:
532
- full_name_label.setdefault(token, label)
533
- # Also add email-derived names that NER missed
534
  for first, last, full in email_names:
535
  key = first.lower()
536
  if key not in label_map:
537
  continue
538
- # Add full name, first name, and last name so all forms are caught
539
  for variant in [full, first, last]:
540
  if variant not in full_name_label:
541
  full_name_label[variant] = label_map[key]
542
 
543
- # Build token_label AFTER full_name_label is complete (including email names)
544
- # so last names like "Obi" or "Reeves" from email are included
545
  token_label: dict = {}
546
  for full_name, label in full_name_label.items():
547
  for token in full_name.split():
548
- if len(token) >= 4 and token not in token_label:
549
  token_label[token] = label
550
 
551
  # Replace full names first (longest first), then individual tokens
@@ -842,4 +817,4 @@ I, Prof. Michael Davies, am delighted to recommend her for this position."""
842
  result = bd.anonymize_document(test_cv)
843
  print(f"SURFACE:\n{result['surface_anonymized']}\n")
844
  print(f"FULLY ANONYMIZED:\n{result['fully_anonymized']}")
845
- print(f"Sustainability: {result['sustainability']}")
 
18
 
19
  import re
20
  import torch
21
+ import spacy
22
  from collections import Counter
23
  from transformers import (
24
  pipeline,
 
59
  Initialize all models. Call once at startup, reuse for every request.
60
 
61
  Args:
62
+ llm_model_name: HuggingFace instruction-tuned model for rewriting
63
+ and CV anonymization. Defaults to Qwen2.5-7B-Instruct
64
+ loaded in 4-bit to fit on CPU-only Spaces (~4.5 GB RAM).
65
  """
66
  print("Loading bias detection model...")
67
  self.classifier = pipeline(
 
70
  device=0 if torch.cuda.is_available() else -1,
71
  )
72
 
73
+ print("Loading spaCy NER model...")
74
+ self.nlp = spacy.load("en_core_web_sm")
 
 
 
 
 
75
 
76
  print(f"Loading {llm_model_name}...")
77
  self.tokenizer = AutoTokenizer.from_pretrained(llm_model_name)
 
418
  def _surface_anonymize(self, text: str) -> str:
419
  """
420
  Step A: Replace emails with [EMAIL].
421
+ Step B: spaCy NER → frequency-based person labelling.
422
  Most-mentioned person → [CANDIDATE].
423
  Others → [PERSON_2], [PERSON_3], … in order of first appearance.
424
  Step C: Rule-based pronoun / title / gendered-noun replacement.
 
442
  text,
443
  )
444
 
445
+ # --- Step B: Person names via spaCy NER ---
446
+ doc = self.nlp(anonymized)
447
+
448
+ # Collect all PERSON spans and count mention frequency per canonical name
449
+ # (use the first token as a rough canonical key to handle "Sarah" vs "Sarah Johnson")
450
+ # Academic degree keywords — spaCy sometimes mislabels degree names as PERSON
451
+ # e.g. "MSc Machine Learning", "BSc Mathematics", "PhD Computer Science"
452
+ DEGREE_KEYWORDS = {
453
+ "msc", "bsc", "ba", "ma", "mba", "phd", "llb", "llm", "beng", "meng",
454
+ "doctorate", "bachelor", "master", "masters", "graduate", "postgraduate",
455
+ }
456
+
457
+ person_spans = [
458
+ (ent.start_char, ent.end_char, ent.text)
459
+ for ent in doc.ents
460
+ if ent.label_ == "PERSON"
461
+ and not any(token.lower() in DEGREE_KEYWORDS for token in ent.text.split())
462
+ ]
463
 
464
  # Count frequency by normalised name (lower-case first token)
465
  name_freq: Counter = Counter()
 
470
  if key not in first_seen:
471
  first_seen[key] = start
472
 
473
+ # Seed name_freq with email-derived names not already found by spaCy
474
  for first, last, full in email_names:
475
  key = first.lower()
476
  if key not in name_freq:
 
491
  for i, k in enumerate(other_keys, start=2):
492
  label_map[k] = f"[PERSON_{i}]"
493
 
494
+ # Replace NER spans in reverse order to preserve char offsets
495
+ for start, end, name in reversed(person_spans):
 
 
 
496
  key = name.strip().lower().split()[0]
497
  label = label_map.get(key, "[PERSON]")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498
  anonymized = anonymized[:start] + label + anonymized[end:]
499
 
500
  # Second pass: regex sweep for any remaining occurrences of known names
501
  # (catches names spaCy missed because they were next to org/title context).
502
  # Sort longest-first to avoid partial replacements.
503
  full_name_label: dict = {}
504
+ for start, end, name in person_spans:
505
+ key = name.strip().lower().split()[0]
506
+ label = label_map.get(key, "[PERSON]")
507
  full_name_label[name.strip()] = label
508
+ # Also add email-derived names that spaCy missed
 
 
 
 
509
  for first, last, full in email_names:
510
  key = first.lower()
511
  if key not in label_map:
512
  continue
513
+ # Add both full name and individual tokens so all forms are caught
514
  for variant in [full, first, last]:
515
  if variant not in full_name_label:
516
  full_name_label[variant] = label_map[key]
517
 
518
+ # Also add individual tokens (first name, last name) for each known person
519
+ # so partial matches like "[CANDIDATE] Obi" get cleaned up
520
  token_label: dict = {}
521
  for full_name, label in full_name_label.items():
522
  for token in full_name.split():
523
+ if len(token) > 2 and token not in token_label:
524
  token_label[token] = label
525
 
526
  # Replace full names first (longest first), then individual tokens
 
817
  result = bd.anonymize_document(test_cv)
818
  print(f"SURFACE:\n{result['surface_anonymized']}\n")
819
  print(f"FULLY ANONYMIZED:\n{result['fully_anonymized']}")
820
+ print(f"Sustainability: {result['sustainability']}")
requirements.txt CHANGED
@@ -1,6 +1,10 @@
1
- transformers>=4.53.0,<5.0.0
2
  torch>=2.0.0
3
  accelerate
4
  gradio
5
  numpy<2.0.0
 
 
 
 
6
  codecarbon>=2.4.0
 
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
  codecarbon>=2.4.0