ara-route-parser / src /data_generator.py
Amr-Yasserr's picture
feat: complete ui redesign, direct loading architecture, and pkl removal
bdc3fd6
Raw History Blame Contribute Delete
14.1 kB
"""
Synthetic NER Training Data Generator for Egyptian Arabic Route Extraction.
Generates BIO-annotated training samples from locations.json using:
- Template-based utterance generation with Egyptian dialect patterns
- ASR noise injection (character substitution, deletion, insertion)
- Data augmentation (synonym replacement, noise word injection, word dropout)
- Balanced origin/destination coverage
"""
import json
import random
import re
import os
import csv
# ============================================================
# Configuration
# ============================================================
RANDOM_SEED = 42
NUM_SAMPLES = 2000 # Total samples to generate
NOISE_RATIO = 0.3 # 30% of samples get ASR noise
FILLER_RATIO = 0.4 # 40% of samples get Egyptian filler words
random.seed(RANDOM_SEED)
# ============================================================
# Egyptian Arabic Sentence Templates
# ============================================================
# {ORIGIN} and {DEST} are placeholders for location names
# These cover the full range of Egyptian colloquial routing patterns
TEMPLATES_ORIGIN_DEST = [
# Standard patterns: ู…ู† [origin] ุงู„ู‰/ู„ู€ [dest]
"ู…ู† {ORIGIN} ุงู„ู‰ {DEST}",
"ู…ู† {ORIGIN} ุฅู„ู‰ {DEST}",
"ู…ู† {ORIGIN} ู„{DEST}",
"ู…ู† {ORIGIN} ู„ู„{DEST}",
"ู…ู† {ORIGIN} ุนู„ู‰ {DEST}",
"ู…ู† {ORIGIN} ุฑุงูŠุญ {DEST}",
"ู…ู† {ORIGIN} ุฑุงูŠุญุฉ {DEST}",
"ู…ู† {ORIGIN} ุฑูˆุญ {DEST}",
# Request patterns
"ุนุงูŠุฒ ุงุฑูˆุญ ู…ู† {ORIGIN} ู„ู€ {DEST}",
"ุนุงูŠุฒ ุงุฑูˆุญ {DEST} ู…ู† {ORIGIN}",
"ุนุงูŠุฒุฉ ุงุฑูˆุญ ู…ู† {ORIGIN} ุงู„ู‰ {DEST}",
"ู…ุญุชุงุฌ ุงูˆุตู„ ู…ู† {ORIGIN} ู„ู€ {DEST}",
"ู…ุญุชุงุฌ ุงูˆุตู„ {DEST} ู…ู† {ORIGIN}",
"ุฎุฏู†ูŠ ู…ู† {ORIGIN} ุนู„ู‰ {DEST}",
"ูˆุตู„ู†ูŠ ู…ู† {ORIGIN} ู„ู€ {DEST}",
"ู†ุงุฒู„ ู…ู† {ORIGIN} ุฑุงูŠุญ {DEST}",
"ุทุงู„ุน ู…ู† {ORIGIN} ุนู„ู‰ {DEST}",
"ู…ุงุดูŠ ู…ู† {ORIGIN} ู„ู€ {DEST}",
"ู‡ุฑูˆุญ ู…ู† {ORIGIN} ู„ู€ {DEST}",
"ุฑุงูŠุญ ู…ู† {ORIGIN} ู„ู€ {DEST}",
"ุงู†ุง ุฑุงูŠุญ {DEST} ู…ู† {ORIGIN}",
"ุงู†ุง ุนุงูŠุฒ ุงุฑูˆุญ {DEST} ู…ู† {ORIGIN}",
"ุงู†ุง ููŠ {ORIGIN} ุนุงูŠุฒ ุงุฑูˆุญ {DEST}",
"ุงู†ุง ููŠ {ORIGIN} ุฑุงูŠุญ {DEST}",
"ุงู†ุง ูˆุงู‚ู ููŠ {ORIGIN} ุนุงูŠุฒ {DEST}",
# Question patterns
"ุงุฒุงูŠ ุงุฑูˆุญ ู…ู† {ORIGIN} ู„ู€ {DEST}",
"ุงูˆุตู„ {DEST} ู…ู† {ORIGIN} ุงุฒุงูŠ",
"ููŠู‡ ุงุชูˆุจูŠุณ ู…ู† {ORIGIN} ู„ู€ {DEST}",
"ุงูŠู‡ ุงู„ู…ูˆุงุตู„ุงุช ู…ู† {ORIGIN} ู„ู€ {DEST}",
"ุงู„ู…ุชุฑูˆ ู…ู† {ORIGIN} ู„ู€ {DEST}",
# Short/informal patterns
"{ORIGIN} {DEST}",
"ู…ู† {ORIGIN} {DEST}",
"{DEST} ู…ู† {ORIGIN}",
"ุฑุงูŠุญ {DEST} ู…ู† {ORIGIN}",
"ุงุฑูˆุญ {DEST} ู…ู† {ORIGIN}",
]
# Templates with only destination (no explicit origin)
TEMPLATES_DEST_ONLY = [
"ุนุงูŠุฒ ุงุฑูˆุญ {DEST}",
"ุนุงูŠุฒุฉ ุงุฑูˆุญ {DEST}",
"ุฑุงูŠุญ {DEST}",
"ู…ุญุชุงุฌ ุงูˆุตู„ {DEST}",
"ุฎุฏู†ูŠ {DEST}",
"ูˆุตู„ู†ูŠ {DEST}",
"ุงุฑูˆุญ {DEST} ุงุฒุงูŠ",
"ุนุงูŠุฒ {DEST}",
"ุนู„ู‰ {DEST}",
"ู„{DEST}",
"ู‡ุฑูˆุญ {DEST}",
"ู†ุงุฒู„ {DEST}",
]
# Templates with only origin (no explicit destination)
TEMPLATES_ORIGIN_ONLY = [
"ุงู†ุง ููŠ {ORIGIN}",
"ุงู†ุง ูˆุงู‚ู ููŠ {ORIGIN}",
"ุทุงู„ุน ู…ู† {ORIGIN}",
"ู†ุงุฒู„ ู…ู† {ORIGIN}",
"ู…ุงุดูŠ ู…ู† {ORIGIN}",
"ู…ู† {ORIGIN}",
]
# Egyptian filler/noise words that appear naturally in speech
FILLER_PREFIXES = [
"ู„ูˆ ุณู…ุญุช ", "ู…ู† ูุถู„ูƒ ", "ูŠุง ุฑูŠุณ ", "ูŠุง ุจุงุดุง ", "ูŠุง ุญุฌ ",
"ูŠุง ูƒุงุจุชู† ", "ู…ู…ูƒู† ", "ุจุณุฑุนุฉ ", "ูŠุง ู…ุนู„ู… ", "ูŠุง ุนู… ",
"ุทุจ ", "ูŠู„ุง ", "ุจู‚ูˆู„ูƒ ", "ุงุณู…ุน ", "ุดูˆู ",
]
FILLER_SUFFIXES = [
" ู„ูˆ ุณู…ุญุช", " ู…ู† ูุถู„ูƒ", " ูŠุง ุฑูŠุณ", " ุจุณุฑุนุฉ", " ุฏู„ูˆู‚ุชูŠ",
" ูŠุง ู…ุนู„ู…", " ูŠุง ุจุงุดุง", " ุจุงู„ุฑุงุญุฉ", " ุงู„ู„ู‡ ูŠุฎู„ูŠูƒ",
" ูŠุนู†ูŠ", " ูƒุฏู‡", " ุจู‚ู‰",
]
# ============================================================
# ASR Noise Simulation
# ============================================================
# Common Arabic character confusions in ASR output
CHAR_CONFUSIONS = {
'ุง': ['ุฃ', 'ุฅ', 'ุข', 'ุก'],
'ุฃ': ['ุง', 'ุฅ', 'ุข'],
'ุฅ': ['ุง', 'ุฃ', 'ุข'],
'ุข': ['ุง', 'ุฃ', 'ุฅ'],
'ุฉ': ['ู‡', 'ุช'],
'ู‡': ['ุฉ'],
'ู‰': ['ูŠ', 'ุง'],
'ูŠ': ['ู‰'],
'ุค': ['ูˆ', 'ุก'],
'ุฆ': ['ูŠ', 'ุก'],
'ุถ': ['ุธ'],
'ุธ': ['ุถ'],
'ุฐ': ['ุฒ', 'ุฏ'],
'ุซ': ['ุณ', 'ุช'],
'ุต': ['ุณ'],
'ู‚': ['ูƒ', 'ุฃ', 'ุก'],
'ุน': ['ุง'],
'ุบ': ['ู‚'],
}
def inject_asr_noise(text: str, noise_level: float = 0.15) -> str:
"""Simulate ASR transcription errors on Arabic text."""
chars = list(text)
result = []
for ch in chars:
if ch in CHAR_CONFUSIONS and random.random() < noise_level:
result.append(random.choice(CHAR_CONFUSIONS[ch]))
elif ch != ' ' and random.random() < noise_level * 0.3:
# Character deletion
continue
elif ch != ' ' and random.random() < noise_level * 0.2:
# Character duplication
result.append(ch)
result.append(ch)
else:
result.append(ch)
return ''.join(result)
# ============================================================
# BIO Tag Generator
# ============================================================
def tokenize_arabic(text: str) -> list:
"""Simple whitespace tokenizer for Arabic text."""
return text.split()
def create_bio_tags(text: str, origin: str, destination: str) -> list:
"""
Create BIO tags for a text given origin and destination spans.
Returns list of (token, tag) tuples.
"""
tokens = tokenize_arabic(text)
tags = ['O'] * len(tokens)
if origin:
origin_tokens = tokenize_arabic(origin)
_mark_span(tokens, tags, origin_tokens, 'ORIGIN')
if destination:
dest_tokens = tokenize_arabic(destination)
_mark_span(tokens, tags, dest_tokens, 'DEST')
return list(zip(tokens, tags))
def _mark_span(tokens, tags, entity_tokens, entity_type):
"""Find and mark an entity span in the token list."""
entity_len = len(entity_tokens)
for i in range(len(tokens) - entity_len + 1):
# Check if this position matches the entity
if tokens[i:i + entity_len] == entity_tokens:
# Only mark if all positions are currently 'O' (avoid double-tagging)
if all(tags[j] == 'O' for j in range(i, i + entity_len)):
tags[i] = f'B-{entity_type}'
for j in range(i + 1, i + entity_len):
tags[j] = f'I-{entity_type}'
break # Mark first occurrence only
# ============================================================
# Data Generator
# ============================================================
def load_locations(filepath: str) -> list:
"""Load and filter locations (exclude route types)."""
with open(filepath, 'r', encoding='utf-8') as f:
locations = json.load(f)
# Filter out route entries (M5, M8, etc.) โ€” these aren't locations
return [loc for loc in locations if loc.get('type') != 'route']
def get_location_name(location: dict) -> str:
"""Get a location name, randomly choosing from canonical name or aliases."""
names = [location['name']]
if location.get('aliases'):
# Only include Arabic aliases (skip English)
arabic_aliases = [a for a in location['aliases'] if any('\u0600' <= c <= '\u06FF' for c in a)]
names.extend(arabic_aliases)
return random.choice(names)
def generate_sample(locations: list, template_type: str = 'both') -> dict:
"""Generate a single training sample."""
origin_loc = random.choice(locations)
dest_loc = random.choice(locations)
# Ensure origin != destination
while dest_loc['name'] == origin_loc['name']:
dest_loc = random.choice(locations)
origin_name = get_location_name(origin_loc)
dest_name = get_location_name(dest_loc)
if template_type == 'both':
template = random.choice(TEMPLATES_ORIGIN_DEST)
text = template.replace('{ORIGIN}', origin_name).replace('{DEST}', dest_name)
origin_span = origin_name
dest_span = dest_name
elif template_type == 'dest_only':
template = random.choice(TEMPLATES_DEST_ONLY)
text = template.replace('{DEST}', dest_name)
origin_span = None
dest_span = dest_name
elif template_type == 'origin_only':
template = random.choice(TEMPLATES_ORIGIN_ONLY)
text = template.replace('{ORIGIN}', origin_name)
origin_span = origin_name
dest_span = None
else:
raise ValueError(f"Unknown template_type: {template_type}")
# Add filler words
if random.random() < FILLER_RATIO:
if random.random() < 0.5:
text = random.choice(FILLER_PREFIXES) + text
else:
text += random.choice(FILLER_SUFFIXES)
# Create clean BIO tags BEFORE noise injection
bio_tags = create_bio_tags(text, origin_span, dest_span)
# Optionally inject ASR noise (on the text only, tags remain aligned to original)
noisy_text = None
if random.random() < NOISE_RATIO:
noisy_text = inject_asr_noise(text, noise_level=0.12)
# Re-tokenize and re-tag with noisy text
# For noisy samples, we inject noise into the entity spans too
noisy_origin = inject_asr_noise(origin_span, 0.12) if origin_span else None
noisy_dest = inject_asr_noise(dest_span, 0.12) if dest_span else None
bio_tags_noisy = create_bio_tags(noisy_text, noisy_origin, noisy_dest)
return {
'text': text,
'origin': origin_span,
'destination': dest_span,
'bio_tags': bio_tags,
'noisy_text': noisy_text,
'noisy_bio_tags': bio_tags_noisy if noisy_text else None,
}
def generate_dataset(locations_path: str, output_dir: str, num_samples: int = NUM_SAMPLES):
"""Generate the full NER training dataset."""
locations = load_locations(locations_path)
print(f"Loaded {len(locations)} locations (excluding routes)")
samples = []
# Distribution: 70% both, 20% dest_only, 10% origin_only
for i in range(num_samples):
r = random.random()
if r < 0.70:
template_type = 'both'
elif r < 0.90:
template_type = 'dest_only'
else:
template_type = 'origin_only'
sample = generate_sample(locations, template_type)
samples.append(sample)
# If noisy variant exists, add it as a separate sample
if sample['noisy_text']:
samples.append({
'text': sample['noisy_text'],
'origin': sample['origin'],
'destination': sample['destination'],
'bio_tags': sample['noisy_bio_tags'],
'noisy_text': None,
'noisy_bio_tags': None,
})
# Shuffle
random.shuffle(samples)
# Split: 80% train, 10% val, 10% test
n = len(samples)
train_end = int(n * 0.8)
val_end = int(n * 0.9)
train_samples = samples[:train_end]
val_samples = samples[train_end:val_end]
test_samples = samples[val_end:]
os.makedirs(output_dir, exist_ok=True)
# Save in CoNLL-style format (token\ttag per line, blank line between sentences)
for split_name, split_data in [('train', train_samples), ('val', val_samples), ('test', test_samples)]:
filepath = os.path.join(output_dir, f'{split_name}.txt')
with open(filepath, 'w', encoding='utf-8') as f:
for sample in split_data:
for token, tag in sample['bio_tags']:
f.write(f'{token}\t{tag}\n')
f.write('\n') # Blank line separates sentences
print(f" {split_name}: {len(split_data)} samples -> {filepath}")
# Also save as JSON for easier inspection
json_path = os.path.join(output_dir, 'dataset_full.json')
json_samples = []
for s in samples:
json_samples.append({
'text': s['text'],
'origin': s['origin'],
'destination': s['destination'],
'tokens': [t for t, _ in s['bio_tags']],
'tags': [tag for _, tag in s['bio_tags']],
})
with open(json_path, 'w', encoding='utf-8') as f:
json.dump(json_samples, f, ensure_ascii=False, indent=2)
print(f" Full dataset JSON -> {json_path}")
# Print statistics
print(f"\n{'='*50}")
print(f"Dataset Generation Complete")
print(f"{'='*50}")
print(f"Total samples: {len(samples)}")
print(f" Train: {len(train_samples)}")
print(f" Val: {len(val_samples)}")
print(f" Test: {len(test_samples)}")
# Count tag distribution
tag_counts = {}
for s in samples:
for _, tag in s['bio_tags']:
tag_counts[tag] = tag_counts.get(tag, 0) + 1
print(f"\nTag Distribution:")
for tag, count in sorted(tag_counts.items()):
print(f" {tag}: {count}")
if __name__ == '__main__':
# Resolve paths
script_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(script_dir)
# Try to find locations.json
locations_candidates = [
os.path.join(project_root, 'data', 'locations.json'),
os.path.join(script_dir, 'locations.json'),
r'c:\Users\tabark\.gemini\antigravity\scratch\signn\hf_space\src\bridge\locations.json',
]
locations_path = None
for candidate in locations_candidates:
if os.path.exists(candidate):
locations_path = candidate
break
if not locations_path:
print("ERROR: Could not find locations.json")
print("Searched:", locations_candidates)
exit(1)
output_dir = os.path.join(project_root, 'data', 'ner_dataset')
print(f"Using locations: {locations_path}")
print(f"Output directory: {output_dir}")
print()
generate_dataset(locations_path, output_dir)