DevPatel0611/TruthLens
1
1"""2stage1_ingestion.py — Load, unify, deduplicate, and persist all datasets.3 4Reads from five dataset sources (ISOT, LIAR, Kaggle Combined / News_dataset,5Multi-Domain / overall, and supplementary training folder), maps them into a6single canonical schema, performs Sentence-BERT deduplication, and writes the7result to ``data/processed/unified.csv`` together with label-distribution8statistics in ``data/processed/stats.json``.9 10Usage:11 python -m src.stage1_ingestion # from fake_news_detection/12 python src/stage1_ingestion.py # direct execution13"""14 15from __future__ import annotations16 17import json18import logging19import os20import sys21import time22import uuid23from pathlib import Path24from typing import Dict, List, Optional, Tuple25from urllib.parse import urlparse26 27import pandas as pd28import yaml29 30# ── Ensure project root is on sys.path when running directly ──31_SCRIPT_DIR = Path(__file__).resolve().parent32_PROJECT_ROOT = _SCRIPT_DIR.parent33if str(_PROJECT_ROOT) not in sys.path:34 sys.path.insert(0, str(_PROJECT_ROOT))35 36from src.utils.deduplication import deduplicate_dataframe # noqa: E40237from src.utils.text_utils import clean_empty_texts, build_full_text, word_count38 39# ═══════════════════════════════════════════════════════════40# Logger41# ═══════════════════════════════════════════════════════════42logging.basicConfig(43 level=logging.INFO,44 format="%(asctime)s │ %(levelname)-8s │ %(name)s │ %(message)s",45 datefmt="%H:%M:%S",46)47logger = logging.getLogger("stage1_ingestion")48 49 50# ═══════════════════════════════════════════════════════════51# Config loader52# ═══════════════════════════════════════════════════════════53def load_config(config_path: Optional[str] = None) -> dict:54 """Load the YAML configuration file.55 56 Args:57 config_path: Explicit path to ``config.yaml``. Falls back to58 ``<project_root>/config/config.yaml`` if not provided.59 60 Returns:61 Parsed configuration dictionary.62 """63 if config_path is None:64 config_path = str(_PROJECT_ROOT / "config" / "config.yaml")65 with open(config_path, "r", encoding="utf-8") as fh:66 cfg = yaml.safe_load(fh)67 return cfg68 69 70# ═══════════════════════════════════════════════════════════71# Schema constants72# ═══════════════════════════════════════════════════════════73UNIFIED_COLUMNS = [74 "article_id",75 "title",76 "text",77 "source_domain",78 "published_date",79 "has_date",80 "binary_label",81 "dataset_origin",82]83 84 85# ═══════════════════════════════════════════════════════════86# Helper: extract domain from URL87# ═══════════════════════════════════════════════════════════88def extract_domain(url: Optional[str]) -> str:89 """Extract the domain (netloc) from a URL string.90 91 Args:92 url: Raw URL (may be ``None`` or malformed).93 94 Returns:95 Domain string such as ``"reuters.com"`` or ``"unknown"``.96 """97 if not url or not isinstance(url, str):98 return "unknown"99 url = url.strip()100 if not url.startswith(("http://", "https://")):101 url = "http://" + url102 try:103 netloc = urlparse(url).netloc104 # Strip leading 'www.'105 if netloc.startswith("www."):106 netloc = netloc[4:]107 return netloc if netloc else "unknown"108 except Exception:109 return "unknown"110 111 112def _try_parse_date(val) -> pd.Timestamp:113 """Attempt to parse a value into a pandas Timestamp.114 115 Args:116 val: Any date-like value.117 118 Returns:119 ``pd.Timestamp`` or ``pd.NaT`` on failure.120 """121 if pd.isna(val):122 return pd.NaT123 try:124 return pd.to_datetime(val)125 except Exception:126 return pd.NaT127 128 129# ═══════════════════════════════════════════════════════════130# Dataset-specific loaders131# ═══════════════════════════════════════════════════════════132 133def load_isot(dataset_root: str) -> pd.DataFrame:134 """Load the ISOT Fake Real News dataset (``True.csv`` + ``Fake.csv``).135 136 Located at ``<dataset_root>/fake_real/``.137 138 Args:139 dataset_root: Path to the top-level Dataset folder.140 141 Returns:142 DataFrame in the unified schema.143 """144 t0 = time.perf_counter()145 logger.info("Loading ISOT dataset …")146 147 base = os.path.join(dataset_root, "fake_real")148 true_path = os.path.join(base, "True.csv")149 fake_path = os.path.join(base, "Fake.csv")150 151 df_true = pd.read_csv(true_path)152 df_true["binary_label"] = 1153 df_fake = pd.read_csv(fake_path)154 df_fake["binary_label"] = 0155 156 df = pd.concat([df_true, df_fake], ignore_index=True)157 158 # Columns: title, text, subject, date159 records: List[dict] = []160 for _, row in df.iterrows():161 pub_date = _try_parse_date(row.get("date"))162 records.append({163 "article_id": str(uuid.uuid4()),164 "title": str(row.get("title", "") or ""),165 "text": str(row.get("text", "") or ""),166 "source_domain": "unknown", # ISOT has no URL column167 "published_date": pub_date,168 "has_date": not pd.isna(pub_date),169 "binary_label": int(row["binary_label"]),170 "dataset_origin": "isot",171 })172 173 result = pd.DataFrame(records, columns=UNIFIED_COLUMNS)174 logger.info(175 "ISOT loaded: %d rows (True=%d, Fake=%d) in %.1fs",176 len(result),177 (result["binary_label"] == 1).sum(),178 (result["binary_label"] == 0).sum(),179 time.perf_counter() - t0,180 )181 return result182 183 184# ─────────────────────────────────────────────────────────185 186# LIAR label mapping187_LIAR_LABEL_MAP = {188 "true": 1,189 "mostly-true": 1,190 "half-true": 1,191 "false": 0,192 "barely-true": 0,193 "pants-fire": 0,194}195 196_LIAR_COLNAMES = [197 "id", "label", "statement", "subject", "speaker",198 "job_title", "state", "party",199 "barely_true_cnt", "false_cnt", "half_true_cnt",200 "mostly_true_cnt", "pants_fire_cnt",201 "context",202]203 204 205def load_liar(dataset_root: str) -> pd.DataFrame:206 """Load the LIAR dataset (``train.tsv``, ``valid.tsv``, ``test.tsv``).207 208 Six-class labels are mapped to binary via ``_LIAR_LABEL_MAP``.209 210 Args:211 dataset_root: Path to the top-level Dataset folder.212 213 Returns:214 DataFrame in the unified schema.215 """216 t0 = time.perf_counter()217 logger.info("Loading LIAR dataset …")218 219 base = os.path.join(dataset_root, "liar")220 frames: List[pd.DataFrame] = []221 for fname in ("train.tsv", "valid.tsv", "test.tsv"):222 fp = os.path.join(base, fname)223 if os.path.exists(fp):224 tmp = pd.read_csv(fp, sep="\t", header=None, names=_LIAR_COLNAMES)225 frames.append(tmp)226 logger.info(" %s: %d rows", fname, len(tmp))227 228 df = pd.concat(frames, ignore_index=True)229 230 records: List[dict] = []231 for _, row in df.iterrows():232 label_str = str(row.get("label", "")).strip().lower()233 binary = _LIAR_LABEL_MAP.get(label_str)234 if binary is None:235 continue # Skip rows with unrecognised labels236 237 records.append({238 "article_id": str(uuid.uuid4()),239 "title": "", # LIAR has no title240 "text": str(row.get("statement", "") or ""),241 "source_domain": "politifact.com", # All LIAR data from PolitiFact242 "published_date": pd.NaT,243 "has_date": False,244 "binary_label": binary,245 "dataset_origin": "liar",246 })247 248 result = pd.DataFrame(records, columns=UNIFIED_COLUMNS)249 logger.info(250 "LIAR loaded: %d rows (True=%d, Fake=%d) in %.1fs",251 len(result),252 (result["binary_label"] == 1).sum(),253 (result["binary_label"] == 0).sum(),254 time.perf_counter() - t0,255 )256 return result257 258 259# ─────────────────────────────────────────────────────────260 261def load_kaggle_combined(dataset_root: str) -> pd.DataFrame:262 """Load the Kaggle Combined / News_dataset folder.263 264 This folder mirrors the ISOT structure (``True.csv``, ``Fake.csv``).265 266 Args:267 dataset_root: Path to the top-level Dataset folder.268 269 Returns:270 DataFrame in the unified schema.271 """272 t0 = time.perf_counter()273 logger.info("Loading Kaggle Combined (News_dataset) …")274 275 # Note: The actual folder has a trailing space: "News _dataset"276 base = os.path.join(dataset_root, "News _dataset")277 if not os.path.isdir(base):278 # Fallback without space279 base = os.path.join(dataset_root, "News_dataset")280 281 frames: List[pd.DataFrame] = []282 283 for fname in os.listdir(base):284 fpath = os.path.join(base, fname)285 if not fname.lower().endswith(".csv"):286 continue287 try:288 tmp = pd.read_csv(fpath)289 except Exception as exc:290 logger.warning("Could not read %s: %s", fpath, exc)291 continue292 293 # Detect label294 name_lower = fname.lower()295 if "true" in name_lower or "real" in name_lower:296 tmp["binary_label"] = 1297 elif "fake" in name_lower:298 tmp["binary_label"] = 0299 elif "label" in [c.lower() for c in tmp.columns]:300 # Dynamic: if there's a label column, try to map301 label_col = [c for c in tmp.columns if c.lower() == "label"][0]302 tmp["binary_label"] = tmp[label_col].apply(303 lambda x: 1 if str(x).strip().lower() in ("1", "true", "real") else 0304 )305 else:306 logger.warning("Cannot determine label for %s — skipping.", fname)307 continue308 309 frames.append(tmp)310 logger.info(" %s: %d rows", fname, len(tmp))311 312 if not frames:313 logger.warning("No CSV files found in Kaggle Combined folder.")314 return pd.DataFrame(columns=UNIFIED_COLUMNS)315 316 df = pd.concat(frames, ignore_index=True)317 318 # Detect column names dynamically319 col_map = {c.lower().strip(): c for c in df.columns}320 321 title_col = col_map.get("title")322 text_col = col_map.get("text") or col_map.get("article") or col_map.get("content")323 date_col = col_map.get("date") or col_map.get("published_date")324 325 records: List[dict] = []326 for _, row in df.iterrows():327 pub_date = _try_parse_date(row.get(date_col)) if date_col else pd.NaT328 records.append({329 "article_id": str(uuid.uuid4()),330 "title": str(row.get(title_col, "") or "") if title_col else "",331 "text": str(row.get(text_col, "") or "") if text_col else "",332 "source_domain": "unknown",333 "published_date": pub_date,334 "has_date": not pd.isna(pub_date),335 "binary_label": int(row["binary_label"]),336 "dataset_origin": "kaggle_combined",337 })338 339 result = pd.DataFrame(records, columns=UNIFIED_COLUMNS)340 logger.info(341 "Kaggle Combined loaded: %d rows (True=%d, Fake=%d) in %.1fs",342 len(result),343 (result["binary_label"] == 1).sum(),344 (result["binary_label"] == 0).sum(),345 time.perf_counter() - t0,346 )347 return result348 349 350# ─────────────────────────────────────────────────────────351 352def _load_txt_folder(folder: str, label: int) -> List[dict]:353 """Read all ``.txt`` files in *folder* and return a list of record dicts.354 355 The first non-empty line is treated as the title; the remainder is the356 body text.357 358 Args:359 folder: Directory containing ``.txt`` article files.360 label: Binary label (0 = Fake, 1 = True) to assign.361 362 Returns:363 List of dicts suitable for DataFrame construction.364 """365 records: List[dict] = []366 if not os.path.isdir(folder):367 return records368 for fname in sorted(os.listdir(folder)):369 if not fname.endswith(".txt"):370 continue371 fpath = os.path.join(folder, fname)372 try:373 with open(fpath, "r", encoding="utf-8", errors="replace") as fh:374 lines = fh.read().strip().splitlines()375 except Exception:376 continue377 title = lines[0].strip() if lines else ""378 body = "\n".join(lines[1:]).strip() if len(lines) > 1 else ""379 records.append({380 "article_id": str(uuid.uuid4()),381 "title": title,382 "text": body,383 "source_domain": "unknown",384 "published_date": pd.NaT,385 "has_date": False,386 "binary_label": label,387 "dataset_origin": "multi_domain",388 })389 return records390 391 392def load_multi_domain(dataset_root: str) -> pd.DataFrame:393 """Load the Multi-Domain Fake News dataset (``overall/`` folder).394 395 Structure::396 397 overall/overall/398 fake/ → .txt files (label 0)399 real/ → .txt files (label 1)400 celebrityDataset/401 fake/ → .txt files (label 0)402 legit/ → .txt files (label 1)403 404 Args:405 dataset_root: Path to the top-level Dataset folder.406 407 Returns:408 DataFrame in the unified schema.409 """410 t0 = time.perf_counter()411 logger.info("Loading Multi-Domain dataset …")412 413 base = os.path.join(dataset_root, "overall", "overall")414 records: List[dict] = []415 416 # Main fake / real folders417 records.extend(_load_txt_folder(os.path.join(base, "fake"), label=0))418 records.extend(_load_txt_folder(os.path.join(base, "real"), label=1))419 420 # Celebrity sub-dataset421 celeb = os.path.join(base, "celebrityDataset")422 records.extend(_load_txt_folder(os.path.join(celeb, "fake"), label=0))423 records.extend(_load_txt_folder(os.path.join(celeb, "legit"), label=1))424 425 result = pd.DataFrame(records, columns=UNIFIED_COLUMNS)426 logger.info(427 "Multi-Domain loaded: %d rows (True=%d, Fake=%d) in %.1fs",428 len(result),429 (result["binary_label"] == 1).sum(),430 (result["binary_label"] == 0).sum(),431 time.perf_counter() - t0,432 )433 return result434 435 436# ─────────────────────────────────────────────────────────437 438def load_training_folder(dataset_root: str) -> pd.DataFrame:439 """Load supplementary training data from ``training/training/``.440 441 Structure mirrors multi-domain with sub-datasets ``celebrityDataset``442 and ``fakeNewsDataset``, each containing ``fake/`` and ``legit/`` folders.443 444 Args:445 dataset_root: Path to the top-level Dataset folder.446 447 Returns:448 DataFrame in the unified schema.449 """450 t0 = time.perf_counter()451 logger.info("Loading supplementary training folder …")452 453 base = os.path.join(dataset_root, "training", "training")454 records: List[dict] = []455 456 for subdir in ("celebrityDataset", "fakeNewsDataset"):457 sub_path = os.path.join(base, subdir)458 if not os.path.isdir(sub_path):459 continue460 fake_recs = _load_txt_folder(os.path.join(sub_path, "fake"), label=0)461 legit_recs = _load_txt_folder(os.path.join(sub_path, "legit"), label=1)462 for r in fake_recs + legit_recs:463 r["dataset_origin"] = f"training_{subdir}"464 records.extend(fake_recs + legit_recs)465 logger.info(" %s: %d fake + %d legit", subdir, len(fake_recs), len(legit_recs))466 467 result = pd.DataFrame(records, columns=UNIFIED_COLUMNS)468 logger.info(469 "Training folder loaded: %d rows (True=%d, Fake=%d) in %.1fs",470 len(result),471 (result["binary_label"] == 1).sum(),472 (result["binary_label"] == 0).sum(),473 time.perf_counter() - t0,474 )475 return result476 477 478# ─────────────────────────────────────────────────────────479 480def load_testing_dataset(dataset_root: str) -> pd.DataFrame:481 """Load the sacred hold-out Testing_dataset (never used for training).482 483 Structure::484 485 Testing_dataset/testingSet/486 fake/ → .txt files (label 0)487 real/ → .txt files (label 1)488 489 The catalog CSVs in this folder provide metadata; the actual article490 bodies live in the ``fake/`` and ``real/`` sub-folders.491 492 Args:493 dataset_root: Path to the top-level Dataset folder.494 495 Returns:496 DataFrame in the unified schema with ``dataset_origin = "testing"``.497 """498 t0 = time.perf_counter()499 logger.info("Loading Testing dataset (hold-out) …")500 501 base = os.path.join(dataset_root, "Testing_dataset", "testingSet")502 records: List[dict] = []503 504 fake_recs = _load_txt_folder(os.path.join(base, "fake"), label=0)505 real_recs = _load_txt_folder(os.path.join(base, "real"), label=1)506 for r in fake_recs + real_recs:507 r["dataset_origin"] = "testing"508 records.extend(fake_recs + real_recs)509 510 # Optionally enrich with catalog metadata511 for catalog_name, label in [("Catalog - Fake Articles.csv", 0), ("Catalog - Real Articles.csv", 1)]:512 cat_path = os.path.join(base, catalog_name)513 if os.path.exists(cat_path):514 try:515 cat = pd.read_csv(cat_path)516 logger.info(" Catalog %s: %d entries", catalog_name, len(cat))517 except Exception as exc:518 logger.warning(" Could not read catalog %s: %s", catalog_name, exc)519 520 result = pd.DataFrame(records, columns=UNIFIED_COLUMNS)521 logger.info(522 "Testing dataset loaded: %d rows (True=%d, Fake=%d) in %.1fs",523 len(result),524 (result["binary_label"] == 1).sum(),525 (result["binary_label"] == 0).sum(),526 time.perf_counter() - t0,527 )528 return result529 530 531# ═══════════════════════════════════════════════════════════532# Main ingestion pipeline533# ═══════════════════════════════════════════════════════════534 535def run_ingestion(cfg: dict) -> pd.DataFrame:536 """Execute the full Stage 1 ingestion pipeline.537 538 Steps:539 1. Load all five dataset sources.540 2. Concatenate into a single DataFrame.541 3. Run Sentence-BERT deduplication.542 4. Persist ``unified.csv`` and ``stats.json``.543 544 Args:545 cfg: Parsed config dictionary (from ``config.yaml``).546 547 Returns:548 The final unified (deduplicated) DataFrame.549 """550 pipeline_t0 = time.perf_counter()551 logger.info("═" * 60)552 logger.info(" STAGE 1 — INGESTION START")553 logger.info("═" * 60)554 555 dataset_root = os.path.abspath(556 os.path.join(str(_PROJECT_ROOT), cfg["paths"]["dataset_root"])557 )558 logger.info("Dataset root resolved to: %s", dataset_root)559 560 # ── Step 1 : Load each dataset ───────────────────────────561 t0 = time.perf_counter()562 df_isot = load_isot(dataset_root)563 df_liar = load_liar(dataset_root)564 df_kaggle = load_kaggle_combined(dataset_root)565 df_multi = load_multi_domain(dataset_root)566 df_training = load_training_folder(dataset_root)567 df_testing = load_testing_dataset(dataset_root)568 load_time = time.perf_counter() - t0569 logger.info("All datasets loaded in %.1fs", load_time)570 571 # ── Step 2 : Concatenate ─────────────────────────────────572 t0 = time.perf_counter()573 all_frames = [df_isot, df_liar, df_kaggle, df_multi, df_training, df_testing]574 df_unified = pd.concat(all_frames, ignore_index=True)575 logger.info(576 "Unified dataset: %d rows (concat took %.1fs)",577 len(df_unified), time.perf_counter() - t0,578 )579 580 # Log per-origin counts581 origin_counts = df_unified["dataset_origin"].value_counts()582 for origin, cnt in origin_counts.items():583 logger.info(" %-30s %6d rows", origin, cnt)584 585 # ── Prep ─────────────────────────────────────────────────586 # FIX 1: Exclude Sacred Hold-out from Dedup587 test_mask = df_unified["dataset_origin"] == "testing"588 test_df = df_unified.loc[test_mask].copy()589 train_pool_df = df_unified.loc[~test_mask].copy()590 591 # FIX 3: Remove empty/near-empty texts from training ONLY592 min_word_count = cfg.get("preprocessing", {}).get("min_word_count", 3)593 train_before = len(train_pool_df)594 train_pool_df = clean_empty_texts(train_pool_df, min_word_count=min_word_count)595 empty_dropped = train_before - len(train_pool_df)596 597 # Flag short texts in test_df instead of dropping them598 test_full = test_df.apply(lambda r: build_full_text(r.get("title", ""), r.get("text", "")), axis=1)599 test_df["short_text_flag"] = test_full.apply(word_count) < min_word_count600 short_test_flagged = int(test_df["short_text_flag"].sum())601 602 logger.info("Sacred test rows preserved: %d (flagged %d short texts)", len(test_df), short_test_flagged)603 604 # ── Step 3 : Deduplication ───────────────────────────────605 dedup_cfg = cfg.get("dataset", {})606 threshold = dedup_cfg.get("dedup_threshold", 0.92)607 batch_size = dedup_cfg.get("dedup_batch_size", 64)608 609 train_pool_df["_dedup_text"] = (610 train_pool_df["title"].fillna("") + " " + train_pool_df["text"].fillna("")611 ).str.strip()612 613 mask_has_text = train_pool_df["_dedup_text"].str.len() > 10614 df_with_text = train_pool_df.loc[mask_has_text].copy()615 df_no_text = train_pool_df.loc[~mask_has_text].copy()616 617 logger.info(618 "Dedup candidates (train pool): %d rows with text, %d skipped (too short)",619 len(df_with_text), len(df_no_text),620 )621 622 if len(df_with_text) > 0:623 exact_counts = len(df_with_text) - len(df_with_text.drop_duplicates(subset=["_dedup_text"]))624 df_deduped, dedup_stats = deduplicate_dataframe(625 df_with_text,626 text_column="_dedup_text",627 threshold=threshold,628 batch_size=batch_size,629 origin_column="dataset_origin",630 )631 total_removed = len(df_with_text) - len(df_deduped)632 semantic_counts = total_removed - exact_counts633 else:634 df_deduped = df_with_text635 dedup_stats = {}636 exact_counts = 0637 semantic_counts = 0638 639 train_pool_deduped = pd.concat([df_deduped, df_no_text], ignore_index=True)640 train_pool_deduped.drop(columns=["_dedup_text"], inplace=True, errors="ignore")641 642 # FIX 2: Stratified Holdout Carve-out643 holdout_cfg = cfg.get("holdout", {})644 stratified_test_size = holdout_cfg.get("stratified_test_size", 0.10)645 random_state = holdout_cfg.get("random_state", 42)646 647 from sklearn.model_selection import StratifiedShuffleSplit648 sss = StratifiedShuffleSplit(n_splits=1, test_size=stratified_test_size, random_state=random_state)649 650 train_pool_deduped = train_pool_deduped.reset_index(drop=True)651 train_idx, held_idx = next(sss.split(train_pool_deduped, train_pool_deduped['binary_label']))652 653 stratified_holdout = train_pool_deduped.iloc[held_idx].copy()654 train_pool_final = train_pool_deduped.iloc[train_idx].copy()655 656 stratified_holdout['dataset_origin'] = 'stratified_holdout'657 658 logger.info("Train pool after carve-out: %d", len(train_pool_final))659 logger.info("Stratified holdout: %d", len(stratified_holdout))660 logger.info("Sacred test set: %d", len(test_df))661 662 train_pool_final['short_text_flag'] = False663 stratified_holdout['short_text_flag'] = False664 665 df_final = pd.concat([666 train_pool_final,667 stratified_holdout,668 test_df669 ], ignore_index=True)670 671 logger.info("Post-dedup and split total: %d rows", len(df_final))672 673 # ── Step 4 : Ensure types ────────────────────────────────674 df_final["published_date"] = pd.to_datetime(675 df_final["published_date"], errors="coerce"676 )677 df_final["has_date"] = df_final["published_date"].notna()678 df_final["binary_label"] = df_final["binary_label"].astype(int)679 680 # ── Step 5 : Save unified CSV + stats ────────────────────681 processed_dir = os.path.join(str(_PROJECT_ROOT), cfg["paths"]["processed_dir"])682 os.makedirs(processed_dir, exist_ok=True)683 684 csv_path = os.path.join(processed_dir, "unified.csv")685 df_final.to_csv(csv_path, index=False)686 logger.info("Saved unified CSV → %s (%d rows)", csv_path, len(df_final))687 688 # Stats689 stats = {690 "total_rows": len(df_final),691 "train_pool_rows": len(train_pool_final),692 "stratified_holdout_rows": len(stratified_holdout),693 "sacred_test_rows": len(test_df),694 "fake_count": int((df_final["binary_label"] == 0).sum()),695 "true_count": int((df_final["binary_label"] == 1).sum()),696 "has_date_ratio": float(df_final["has_date"].mean()),697 "empty_texts_dropped": empty_dropped,698 "short_text_flagged_in_test": short_test_flagged,699 "dedup_removed_exact": exact_counts,700 "dedup_removed_semantic": semantic_counts,701 "per_origin": df_final["dataset_origin"].value_counts().to_dict(),702 "dedup_stats": {k: int(v) for k, v in dedup_stats.items()}703 }704 stats_path = os.path.join(processed_dir, "stats.json")705 with open(stats_path, "w", encoding="utf-8") as fh:706 json.dump(stats, fh, indent=2, default=str)707 logger.info("Saved stats → %s", stats_path)708 709 pipeline_elapsed = time.perf_counter() - pipeline_t0710 logger.info("═" * 60)711 logger.info(" STAGE 1 — INGESTION COMPLETE (%.1fs total)", pipeline_elapsed)712 logger.info("═" * 60)713 714 return df_final715 716 717# ═══════════════════════════════════════════════════════════718# __main__ block for standalone testing719# ═══════════════════════════════════════════════════════════720if __name__ == "__main__":721 cfg = load_config()722 df = run_ingestion(cfg)723 print("\n=== Final Unified Dataset ===")724 print(f"Shape: {df.shape}")725 print(f"\nLabel distribution:\n{df['binary_label'].value_counts()}")726 print(f"\nOrigin distribution:\n{df['dataset_origin'].value_counts()}")727 print(f"\nhas_date ratio: {df['has_date'].mean():.2%}")728 print(f"\nSample rows:\n{df.head(3).to_string()}")729 