scam-nlp-ml / src /preprocess.py
aattyy11's picture
Upload folder using huggingface_hub
28f95b2 verified
Raw
History Blame Contribute Delete
12.7 kB
"""
MuRIL preprocessing: Unicode normalize, mask URLs/phones/PII, ASCII-only lowercasing,
tokenize with google/muril-base-cased, stratified 70/15/15 split → HuggingFace DatasetDict.
"""
from __future__ import annotations
import json
import logging
import re
import unicodedata
from pathlib import Path
import numpy as np
import pandas as pd
from datasets import Dataset, DatasetDict
from sklearn.model_selection import train_test_split
from sklearn.utils.class_weight import compute_class_weight
from transformers import AutoTokenizer, PreTrainedTokenizerBase
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger(__name__)
ROOT = Path(__file__).resolve().parent.parent
INPUT_CSV = ROOT / "data" / "processed" / "combined.csv"
OUTPUT_DIR = ROOT / "data" / "processed" / "tokenized"
TOKENIZER_SAVE_DIR = OUTPUT_DIR / "muril_tokenizer"
MODEL_NAME = "google/muril-base-cased"
MAX_LENGTH = 128
TRAIN_RATIO = 0.70
VAL_RATIO = 0.15
RANDOM_SEED = 42
CUSTOM_TOKENS = ["[URL]", "[PHONE]", "[EMAIL]", "[AMOUNT]", "[CODE]", "[AADHAAR]", "[PAN]"]
# ASCII lowercasing would mangle placeholders ([URL] -> [url]); restore for tokenizer add_tokens.
_PLACEHOLDER_LOWER_TO_CANON = {
"[url]": "[URL]",
"[phone]": "[PHONE]",
"[email]": "[EMAIL]",
"[amount]": "[AMOUNT]",
"[code]": "[CODE]",
"[aadhaar]": "[AADHAAR]",
"[pan]": "[PAN]",
}
INDIC_RANGE_RE = re.compile(r"[\u0900-\u0D7F]+")
_URL_RE = re.compile(r"https?://\S+|www\.\S+", re.IGNORECASE)
_PHONE_RE = re.compile(
r"(\+91[\s\-]?)?[6-9]\d{9}"
r"|\b\d{10}\b"
r"|\b\d{5}[\s\-]\d{5}\b"
r"|\+\d{1,3}[\s\-]\d{6,14}",
)
_EMAIL_RE = re.compile(r"\S+@\S+\.\S+")
_AMOUNT_RE = re.compile(r"₹\s?\d[\d,]*(\.\d+)?|\brs\.?\s?\d[\d,]*", re.IGNORECASE)
_OTP_RE = re.compile(r"\b\d{4,8}\b")
_AADHAAR_RE = re.compile(r"\b\d{4}\s\d{4}\s\d{4}\b")
_PAN_RE = re.compile(r"\b[A-Z]{5}[0-9]{4}[A-Z]\b")
_WHITESPACE = re.compile(r"\s{2,}")
_FLAG_PATTERNS = {
"urgency": re.compile(
r"\b(urgent|immediately|right now|abhi|turant|jaldi|do not disconnect"
r"|tatkaal|asap|within \d+ (minutes?|hours?|seconds?))\b",
re.IGNORECASE,
),
"authority_impersonation": re.compile(
r"\b(cbi|ed|ncb|nia|police|officer|inspector|narcotics|cyber cell"
r"|income tax|enforcement|ib|raw|customs|interpol|court|judge"
r"|sarkar|government|adhikari)\b",
re.IGNORECASE,
),
"threat": re.compile(
r"\b(arrest|warrant|case register|jail|prison|legal action|sue|fir"
r"|criminal|giriaftari|case darj|pakad|band|block|freeze)\b",
re.IGNORECASE,
),
"payment_demand": re.compile(
r"\b(pay|transfer|send money|deposit|upi|neft|rtgs|wire|bhej"
r"|paisa|rupee|amount|fee|fine|penalty|bail|zamaanat)\b",
re.IGNORECASE,
),
"secrecy": re.compile(
r"\b(do not tell|kisi ko mat batao|secret|confidential|family ko mat"
r"|don.t inform|keep quiet|chup raho|private)\b",
re.IGNORECASE,
),
}
def normalize_unicode(text: str) -> str:
return unicodedata.normalize("NFC", text)
def lowercase_ascii_only(text: str) -> str:
return "".join(ch.lower() if ord(ch) < 128 else ch for ch in text)
def replace_sensitive_tokens(text: str) -> str:
text = _AADHAAR_RE.sub("[AADHAAR]", text)
text = _PAN_RE.sub("[PAN]", text)
text = _URL_RE.sub("[URL]", text)
text = _EMAIL_RE.sub("[EMAIL]", text)
text = _PHONE_RE.sub("[PHONE]", text)
text = _AMOUNT_RE.sub("[AMOUNT]", text)
text = _OTP_RE.sub("[CODE]", text)
return text
def clean_whitespace(text: str) -> str:
return _WHITESPACE.sub(" ", text).strip()
def restore_placeholder_tokens(text: str) -> str:
for lower, canon in _PLACEHOLDER_LOWER_TO_CANON.items():
text = text.replace(lower, canon)
return text
def full_normalize(text: str) -> str:
if not isinstance(text, str) or not text.strip():
return ""
text = normalize_unicode(text)
text = replace_sensitive_tokens(text)
text = lowercase_ascii_only(text)
text = restore_placeholder_tokens(text)
text = clean_whitespace(text)
return text
def extract_flags(text: str) -> list[str]:
return [flag for flag, pattern in _FLAG_PATTERNS.items() if pattern.search(text)]
def has_indic_script(text: str) -> bool:
return bool(INDIC_RANGE_RE.search(text))
def load_and_preprocess(path: Path = INPUT_CSV) -> pd.DataFrame:
log.info("Loading combined dataset from %s …", path)
if not path.exists():
raise FileNotFoundError(
f"Missing {path}. Run: python src/data_loader.py",
)
df = pd.read_csv(path)
required = {"text", "label", "category"}
if not required.issubset(df.columns):
raise ValueError(f"CSV missing columns: {required - set(df.columns)}")
log.info(" Raw rows: %d", len(df))
log.info("Extracting scam flags …")
df = df.copy()
df["flags"] = df["text"].apply(extract_flags)
df["flag_count"] = df["flags"].apply(len)
df["has_indic"] = df["text"].apply(has_indic_script)
log.info("Normalizing text (Unicode NFC, [URL]/[PHONE]/…, ASCII lower only) …")
df["text"] = df["text"].apply(full_normalize)
empty_mask = df["text"].str.len() < 5
if empty_mask.sum() > 0:
log.warning(" Dropping %d rows with text < 5 chars after normalization", int(empty_mask.sum()))
df = df[~empty_mask].reset_index(drop=True)
df["label"] = df["label"].astype(int)
log.info(" After normalization: %d rows", len(df))
log.info(" Indic script present: %d rows (%.1f%%)", df["has_indic"].sum(), df["has_indic"].mean() * 100)
scam_mean = df.loc[df["label"] == 1, "flag_count"].mean()
log.info(" Avg flags per scam message: %.2f", scam_mean if np.isfinite(scam_mean) else 0.0)
return df
def _train_val_test_split(df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
test_size = 1.0 - TRAIN_RATIO - VAL_RATIO
val_relative = VAL_RATIO / (TRAIN_RATIO + VAL_RATIO)
def _stratify(series: pd.Series | None):
return series if series is not None and series.nunique() > 1 else None
try:
train_val, test = train_test_split(
df,
test_size=test_size,
stratify=_stratify(df["label"]),
random_state=RANDOM_SEED,
)
train, val = train_test_split(
train_val,
test_size=val_relative,
stratify=_stratify(train_val["label"]),
random_state=RANDOM_SEED,
)
except ValueError as e:
log.warning("Stratified split failed (%s); falling back to random split.", e)
train_val, test = train_test_split(df, test_size=test_size, random_state=RANDOM_SEED)
train, val = train_test_split(train_val, test_size=val_relative, random_state=RANDOM_SEED)
log.info("Split → train: %d | val: %d | test: %d", len(train), len(val), len(test))
for name, split in [("train", train), ("val", val), ("test", test)]:
scam_pct = split["label"].mean() * 100
log.info(" %s: scam=%.1f%%", name, scam_pct)
return train.reset_index(drop=True), val.reset_index(drop=True), test.reset_index(drop=True)
def prepare_tokenizer() -> PreTrainedTokenizerBase:
log.info("Loading tokenizer: %s", MODEL_NAME)
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
log.info(" Vocab size: %d", tokenizer.vocab_size)
log.info(" Max model length: %s", tokenizer.model_max_length)
added = tokenizer.add_tokens(CUSTOM_TOKENS)
log.info(" Added %d custom tokens: %s", added, CUSTOM_TOKENS)
return tokenizer
def tokenize_dataset(
tokenizer: PreTrainedTokenizerBase,
df_dict: dict[str, pd.DataFrame],
) -> DatasetDict:
split_name_map = {"train": "train", "val": "validation", "test": "test"}
def tokenize_batch(batch: dict) -> dict:
encoded = tokenizer(
batch["text"],
truncation=True,
padding="max_length",
max_length=MAX_LENGTH,
return_token_type_ids=True,
)
if "token_type_ids" not in encoded:
encoded["token_type_ids"] = [[0] * MAX_LENGTH for _ in batch["text"]]
return {
"input_ids": encoded["input_ids"],
"attention_mask": encoded["attention_mask"],
"token_type_ids": encoded["token_type_ids"],
"labels": batch["label"],
}
out: dict[str, Dataset] = {}
for key, frame in df_dict.items():
hf_name = split_name_map[key]
ds = Dataset.from_pandas(frame[["text", "label"]], preserve_index=False)
ds = ds.map(
tokenize_batch,
batched=True,
batch_size=256,
desc=f"Tokenizing {hf_name}",
remove_columns=["text", "label"],
)
out[hf_name] = ds
return DatasetDict(out)
def compute_class_weights(train_df: pd.DataFrame) -> list[float]:
classes = np.array([0, 1])
weights = compute_class_weight(
class_weight="balanced",
classes=classes,
y=train_df["label"].values,
)
log.info("Class weights → safe: %.3f | scam: %.3f", weights[0], weights[1])
return weights.tolist()
def inspect_tokenization(text: str, tokenizer: PreTrainedTokenizerBase) -> None:
normalized = full_normalize(text)
flags = extract_flags(text)
encoding = tokenizer(
normalized,
return_tensors="pt",
max_length=MAX_LENGTH,
truncation=True,
padding=False,
return_token_type_ids=True,
)
tokens = tokenizer.convert_ids_to_tokens(encoding["input_ids"][0])
print(f"\nOriginal : {text}")
print(f"Normalized: {normalized}")
print(f"Flags : {flags}")
print(f"Tokens ({len(tokens)}): {tokens}")
print(f"Token IDs : {encoding['input_ids'][0].tolist()}")
def run_pipeline() -> tuple[DatasetDict, list[float], PreTrainedTokenizerBase]:
df = load_and_preprocess()
train_df, val_df, test_df = _train_val_test_split(df)
class_weights = compute_class_weights(train_df)
tokenizer = prepare_tokenizer()
dataset = tokenize_dataset(
tokenizer,
{"train": train_df, "val": val_df, "test": test_df},
)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
dataset.save_to_disk(str(OUTPUT_DIR))
log.info("Tokenized dataset saved → %s", OUTPUT_DIR)
weights_path = OUTPUT_DIR / "class_weights.json"
with open(weights_path, "w", encoding="utf-8") as f:
json.dump({"weights": class_weights, "labels": ["safe", "scam"]}, f, indent=2)
log.info("Class weights saved → %s", weights_path)
TOKENIZER_SAVE_DIR.mkdir(parents=True, exist_ok=True)
tokenizer.save_pretrained(str(TOKENIZER_SAVE_DIR))
log.info("Tokenizer saved → %s", TOKENIZER_SAVE_DIR)
return dataset, class_weights, tokenizer
if __name__ == "__main__":
import sys
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except (OSError, ValueError):
pass
dataset, class_weights, tok = run_pipeline()
print("\n-- Sample token inspection (Hindi / mixed scam) --")
inspect_tokenization(
"CBI officer bol raha hoon. Aapka Aadhaar money laundering case mein hai. "
"Turant ₹50000 UPI se bhejo https://evil.example/pay warna giraftari.",
tok,
)
print("\n-- Sample token inspection (safe message) --")
inspect_tokenization(
"Kal meeting 3 baje hai. Conference room B mein milte hain.",
tok,
)
print("\n-- Dataset summary --")
for split_name, ds in dataset.items():
print(f" {split_name:12s}: {len(ds):5d} examples | columns: {ds.column_names}")
ratio = class_weights[1] / class_weights[0] if class_weights[0] else 0.0
print(f"\nClass weights: safe={class_weights[0]:.3f}, scam={class_weights[1]:.3f}")
if ratio > 2.0 or (1.0 / ratio if ratio else 0) > 2.0:
print(
"Note: imbalance >2:1 — use WeightedRandomSampler or class_weight in loss during training.",
)
print("\nNext: python src/train.py (after you add training script)")