Ano woy commited on
Commit
d6bc3f9
Β·
verified Β·
1 Parent(s): 462cd52

Upload bias_detector.py

Browse files
Files changed (1) hide show
  1. bias_detector.py +61 -36
bias_detector.py CHANGED
@@ -18,7 +18,6 @@
18
 
19
  import re
20
  import torch
21
- import spacy
22
  from collections import Counter
23
  from transformers import (
24
  pipeline,
@@ -59,9 +58,8 @@ class BiasDetector:
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,8 +68,13 @@ class BiasDetector:
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,7 +421,7 @@ Output: "They pursue excellence and they work hard. They have delivered results.
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,24 +445,22 @@ Output: "They pursue excellence and they work hard. They have delivered results.
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,7 +471,7 @@ Output: "They pursue excellence and they work hard. They have delivered results.
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,36 +492,60 @@ Output: "They pursue excellence and they work hard. They have delivered results.
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
 
18
 
19
  import re
20
  import torch
 
21
  from collections import Counter
22
  from transformers import (
23
  pipeline,
 
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
  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
  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
  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
  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
  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