CoolFace
Apppublic

Israelbliz/User-Modeling-Agent

sourceHugging Faceupdated 4mo agoView on Hugging Face
0likes
prepare_data.py452 linesDownload Raw Back to data
1"""2Download and sample Amazon Reviews 2023 — Books, Movies_and_TV, Kindle_Store.3 4Source: McAuley-Lab/Amazon-Reviews-2023 on HuggingFace.5 6The dataset is stored as single, large JSONL files (one per category) under:7    raw/review_categories/<Category>.jsonl8    raw/meta_categories/meta_<Category>.jsonl9 10We stream these files over HTTP, line by line, and cache each phase to disk11so a network hiccup doesn't force us to re-download everything. After a12crash, re-run the script — completed phases are skipped automatically.13 14Disk cache layout (under data/raw/):15    review_<Category>.jsonl     ← raw streamed reviews (per category)16    meta_<Category>.jsonl       ← filtered metadata (per category)17 18Output: data/processed/{reviews,items,users}.parquet19"""20from __future__ import annotations21 22import argparse23import json24import logging25import os26import socket27import time28import urllib.error29import urllib.request30from pathlib import Path31 32import numpy as np33import pandas as pd34 35logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")36log = logging.getLogger(__name__)37 38CATEGORIES = ["Books", "Movies_and_TV", "Kindle_Store"]39 40BASE_URL = "https://huggingface.co/datasets/McAuley-Lab/Amazon-Reviews-2023/resolve/main"41 42DATA_DIR = Path(os.environ.get("DATA_DIR", "./data"))43RAW_DIR = DATA_DIR / "raw"44PROCESSED_DIR = DATA_DIR / "processed"45 46REVIEW_KEEP_KEYS = ("user_id", "parent_asin", "rating", "title", "text",47                    "helpful_vote", "verified_purchase", "timestamp")48META_KEEP_KEYS = ("parent_asin", "title", "description", "features",49                  "categories", "average_rating", "rating_number", "price")50 51DEFAULT_USERS_PER_CATEGORY = 300052DEFAULT_MIN_REVIEWS = 553DEFAULT_MAX_REVIEWS = 5054DEFAULT_TEST_HOLDOUT = 255 56# Network tolerance57NETWORK_TIMEOUT = 300        # 5 minutes per read58RETRY_ATTEMPTS = 459RETRY_BACKOFF_BASE = 5       # seconds; doubles each retry60 61 62# ──────────────────────────────────────────────────────────────────────────────63# Network primitives with retry64# ──────────────────────────────────────────────────────────────────────────────65 66def _open_url(url: str, byte_offset: int = 0):67    """Open a URL with a long timeout and optional Range header for resumes."""68    headers = {"User-Agent": "naijataste-ai/1.0"}69    if byte_offset > 0:70        headers["Range"] = f"bytes={byte_offset}-"71    req = urllib.request.Request(url, headers=headers)72    return urllib.request.urlopen(req, timeout=NETWORK_TIMEOUT)73 74 75def _is_transient(exc: BaseException) -> bool:76    """Network errors we should retry on."""77    if isinstance(exc, (socket.timeout, TimeoutError)):78        return True79    if isinstance(exc, urllib.error.URLError):80        # Most URLErrors wrap transient issues (DNS, conn reset)81        return True82    if isinstance(exc, (ConnectionResetError, ConnectionAbortedError,83                        ConnectionRefusedError)):84        return True85    return False86 87 88# ──────────────────────────────────────────────────────────────────────────────89# Streaming with disk cache90# ──────────────────────────────────────────────────────────────────────────────91 92def stream_to_cache(url: str, cache_path: Path, max_rows: int,93                    progress_every: int = 25_000) -> int:94    """Stream a JSONL URL to a local cache file. Returns rows written.95 96    If cache_path already exists and contains >= max_rows lines, this is a97    no-op. Otherwise writes line-by-line; on network failure, retries with98    exponential backoff and resumes from where we left off.99    """100    if cache_path.exists():101        existing = sum(1 for _ in cache_path.open("r", encoding="utf-8"))102        if existing >= max_rows:103            log.info(f"  cache hit: {cache_path.name} has {existing:,} rows ≥ target {max_rows:,}; skipping download")104            return existing105        log.info(f"  partial cache: {cache_path.name} has {existing:,} rows; resuming")106        rows_so_far = existing107        mode = "a"108    else:109        rows_so_far = 0110        mode = "w"111 112    cache_path.parent.mkdir(parents=True, exist_ok=True)113 114    for attempt in range(1, RETRY_ATTEMPTS + 1):115        try:116            with _open_url(url) as resp, cache_path.open(mode, encoding="utf-8") as fout:117                # If resuming, we need to skip lines we already have.118                # Simpler approach: server doesn't honor byte ranges reliably119                # on HF for line semantics, so we re-stream from start and120                # skip the first `rows_so_far` lines.121                skipped = 0122                for raw in resp:123                    if not raw or raw.isspace():124                        continue125                    if skipped < rows_so_far:126                        skipped += 1127                        continue128                    # Write line as-is (already valid JSONL line ending with \n)129                    text = raw.decode("utf-8", errors="replace")130                    if not text.endswith("\n"):131                        text += "\n"132                    fout.write(text)133                    rows_so_far += 1134                    if rows_so_far % progress_every == 0:135                        log.info(f"    cached {rows_so_far:,} rows…")136                    if rows_so_far >= max_rows:137                        break138            log.info(f"  → cached {rows_so_far:,} rows to {cache_path.name}")139            return rows_so_far140        except Exception as e:141            if not _is_transient(e) or attempt == RETRY_ATTEMPTS:142                raise143            backoff = RETRY_BACKOFF_BASE * (2 ** (attempt - 1))144            log.warning(f"  network error ({type(e).__name__}: {e}); retry {attempt}/{RETRY_ATTEMPTS - 1} in {backoff}s")145            time.sleep(backoff)146            # Recount how much we have on disk before next attempt147            if cache_path.exists():148                rows_so_far = sum(1 for _ in cache_path.open("r", encoding="utf-8"))149                mode = "a"150            else:151                rows_so_far = 0152                mode = "w"153 154    raise RuntimeError("unreachable")155 156 157def stream_filter_to_cache(url: str, cache_path: Path, target_asins: set[str],158                           max_scan: int, progress_every: int = 100_000) -> int:159    """Stream metadata, keep only rows whose parent_asin is in target, cache.160 161    Same retry+resume semantics as stream_to_cache. Returns rows written.162    """163    if cache_path.exists():164        kept_existing = sum(1 for _ in cache_path.open("r", encoding="utf-8"))165        log.info(f"  cache hit: {cache_path.name} has {kept_existing:,} rows; using as-is")166        return kept_existing167 168    cache_path.parent.mkdir(parents=True, exist_ok=True)169    kept = 0170    scanned = 0171    found_asins: set[str] = set()172 173    for attempt in range(1, RETRY_ATTEMPTS + 1):174        try:175            # Truncate cache on retry — we restart scanning from the top176            # (deduplication happens at parquet stage via drop_duplicates)177            with _open_url(url) as resp, cache_path.open("w", encoding="utf-8") as fout:178                kept = 0179                scanned = 0180                found_asins = set()181                for raw in resp:182                    if not raw or raw.isspace():183                        continue184                    scanned += 1185                    try:186                        row = json.loads(raw)187                    except json.JSONDecodeError:188                        continue189                    asin = row.get("parent_asin")190                    if asin in target_asins and asin not in found_asins:191                        text = raw.decode("utf-8", errors="replace") \192                            if isinstance(raw, bytes) else raw193                        if not text.endswith("\n"):194                            text += "\n"195                        fout.write(text)196                        kept += 1197                        found_asins.add(asin)198                        if kept >= len(target_asins):199                            break200                    if scanned % progress_every == 0:201                        log.info(f"    scanned {scanned:,}, kept {kept:,}")202                    if scanned >= max_scan:203                        break204            log.info(f"  → scanned {scanned:,}, cached {kept:,} matching rows to {cache_path.name}")205            return kept206        except Exception as e:207            if not _is_transient(e) or attempt == RETRY_ATTEMPTS:208                raise209            backoff = RETRY_BACKOFF_BASE * (2 ** (attempt - 1))210            log.warning(f"  network error ({type(e).__name__}: {e}); retry {attempt}/{RETRY_ATTEMPTS - 1} in {backoff}s")211            time.sleep(backoff)212 213    raise RuntimeError("unreachable")214 215 216# ──────────────────────────────────────────────────────────────────────────────217# Cache → DataFrame loaders218# ──────────────────────────────────────────────────────────────────────────────219 220def load_reviews_from_cache(cache_path: Path, category: str) -> pd.DataFrame:221    rows = []222    with cache_path.open("r", encoding="utf-8") as f:223        for raw in f:224            try:225                r = json.loads(raw)226            except json.JSONDecodeError:227                continue228            rows.append({k: r.get(k) for k in REVIEW_KEEP_KEYS})229    df = pd.DataFrame(rows)230    df["domain"] = category231    return df232 233 234def load_meta_from_cache(cache_path: Path, category: str) -> pd.DataFrame:235    rows = []236    with cache_path.open("r", encoding="utf-8") as f:237        for raw in f:238            try:239                r = json.loads(raw)240            except json.JSONDecodeError:241                continue242            row = {}243            for k in META_KEEP_KEYS:244                v = r.get(k)245                if isinstance(v, list):246                    v = " ".join(str(x) for x in v if x is not None)247                row[k] = v248            row["domain"] = category249            rows.append(row)250    df = pd.DataFrame(rows)251    if not df.empty:252        for col in ("description", "features"):253            if col in df.columns:254                df[col] = df[col].astype(str).str.slice(0, 2000)255    return df256 257 258# ──────────────────────────────────────────────────────────────────────────────259# Sampling, splits, normalization (unchanged from v3)260# ──────────────────────────────────────────────────────────────────────────────261 262def sample_users(reviews: pd.DataFrame, min_reviews: int, max_reviews: int,263                 target_users: int) -> pd.DataFrame:264    counts = reviews.groupby("user_id").agg(265        n_reviews=("rating", "size"),266        n_domains=("domain", "nunique"),267    ).reset_index()268 269    eligible = counts[(counts["n_reviews"] >= min_reviews)270                      & (counts["n_reviews"] <= max_reviews)]271    log.info(f"{len(eligible):,} users in [{min_reviews},{max_reviews}] reviews")272 273    cross = eligible[eligible["n_domains"] >= 2]274    single = eligible[eligible["n_domains"] == 1]275 276    n_cross = min(len(cross), target_users // 3)277    n_single = min(len(single), target_users - n_cross)278 279    log.info(f"Sampling {n_cross:,} cross-domain + {n_single:,} single-domain users")280    rng = np.random.default_rng(42)281    cross_s = cross.sample(n=n_cross, random_state=rng.integers(1e9)) if n_cross else cross.head(0)282    single_s = single.sample(n=n_single, random_state=rng.integers(1e9)) if n_single else single.head(0)283    return pd.concat([cross_s, single_s], ignore_index=True)284 285 286def build_train_test_splits(reviews: pd.DataFrame, holdout: int) -> pd.DataFrame:287    reviews = reviews.sort_values(["user_id", "timestamp"], ascending=[True, True])288    reviews["rank_within_user"] = reviews.groupby("user_id").cumcount(ascending=False)289    reviews["split"] = np.where(reviews["rank_within_user"] < holdout, "test", "train")290    return reviews.drop(columns=["rank_within_user"])291 292 293def normalize_items_for_parquet(items: pd.DataFrame) -> pd.DataFrame:294    """Coerce messy item-metadata columns to clean dtypes."""295    if items.empty:296        return items297    out = items.copy()298    for col in ("price", "average_rating", "rating_number"):299        if col in out.columns:300            s = out[col].astype(str).str.replace(r"^\$", "", regex=True)301            out[col] = pd.to_numeric(s, errors="coerce")302    for col in ("parent_asin", "title", "description", "features",303                "categories", "domain"):304        if col in out.columns:305            out[col] = out[col].astype(str).replace({"None": "", "nan": ""})306    return out307 308 309def build_user_stats(reviews_train: pd.DataFrame) -> pd.DataFrame:310    def lens(s):311        return s.fillna("").astype(str).str.split().str.len()312    stats = reviews_train.groupby("user_id").agg(313        n_reviews=("rating", "size"),314        avg_rating=("rating", "mean"),315        std_rating=("rating", "std"),316        avg_review_length=("text", lambda s: lens(s).mean()),317        std_review_length=("text", lambda s: lens(s).std()),318        verified_rate=("verified_purchase", "mean"),319        domains=("domain", lambda s: list(s.unique())),320        n_domains=("domain", "nunique"),321    ).reset_index()322    stats["std_rating"] = stats["std_rating"].fillna(0)323    stats["std_review_length"] = stats["std_review_length"].fillna(0)324    return stats325 326 327# ──────────────────────────────────────────────────────────────────────────────328# Main329# ──────────────────────────────────────────────────────────────────────────────330 331def main():332    ap = argparse.ArgumentParser()333    ap.add_argument("--rows-per-category", type=int, default=150_000)334    ap.add_argument("--meta-scan-cap", type=int, default=600_000,335                    help="Max metadata rows to scan per category (smaller=faster)")336    ap.add_argument("--target-users", type=int, default=DEFAULT_USERS_PER_CATEGORY * 3)337    ap.add_argument("--min-reviews", type=int, default=DEFAULT_MIN_REVIEWS)338    ap.add_argument("--max-reviews", type=int, default=DEFAULT_MAX_REVIEWS)339    ap.add_argument("--test-holdout", type=int, default=DEFAULT_TEST_HOLDOUT)340    ap.add_argument("--skip-meta", action="store_true",341                    help="Skip metadata download; use review titles as item info")342    args = ap.parse_args()343 344    PROCESSED_DIR.mkdir(parents=True, exist_ok=True)345    RAW_DIR.mkdir(parents=True, exist_ok=True)346 347    # ── Phase 1: reviews (cached per category) ──────────────────────────────348    log.info("=" * 70)349    log.info("PHASE 1: downloading review files (resumable)")350    log.info("=" * 70)351    for cat in CATEGORIES:352        cache_path = RAW_DIR / f"review_{cat}.jsonl"353        url = f"{BASE_URL}/raw/review_categories/{cat}.jsonl"354        log.info(f"[{cat}] reviews → {cache_path.name}")355        stream_to_cache(url, cache_path, max_rows=args.rows_per_category)356 357    all_reviews = []358    for cat in CATEGORIES:359        cache_path = RAW_DIR / f"review_{cat}.jsonl"360        df = load_reviews_from_cache(cache_path, cat)361        log.info(f"[{cat}] loaded {len(df):,} reviews from cache")362        all_reviews.append(df)363    reviews = pd.concat(all_reviews, ignore_index=True)364    log.info(f"Total raw reviews: {len(reviews):,}")365 366    # ── Phase 2: clean + sample users + splits ─────────────────────────────367    log.info("=" * 70)368    log.info("PHASE 2: filtering, sampling, splits")369    log.info("=" * 70)370    reviews = reviews.dropna(subset=["user_id", "parent_asin", "rating", "text"])371    reviews = reviews[reviews["text"].astype(str).str.len() > 20]372    log.info(f"After cleaning: {len(reviews):,} reviews")373 374    user_sample = sample_users(reviews, args.min_reviews, args.max_reviews,375                               args.target_users)376    keep_users = set(user_sample["user_id"])377    reviews = reviews[reviews["user_id"].isin(keep_users)].reset_index(drop=True)378    log.info(f"After user filter: {len(reviews):,} reviews / {len(keep_users):,} users")379 380    reviews = build_train_test_splits(reviews, holdout=args.test_holdout)381    n_train = (reviews["split"] == "train").sum()382    n_test = (reviews["split"] == "test").sum()383    log.info(f"Train: {n_train:,}  |  Test: {n_test:,}")384 385    # ── Phase 3: metadata (cached per category) ─────────────────────────────386    log.info("=" * 70)387    log.info("PHASE 3: downloading item metadata (resumable)")388    log.info("=" * 70)389    if args.skip_meta:390        log.info("--skip-meta set; building minimal catalog from review titles")391        items = (reviews.groupby(["parent_asin", "domain"])392                        .agg(title=("title", "first"))393                        .reset_index())394        items["description"] = ""395        items["features"] = ""396        items["categories"] = ""397        items["average_rating"] = None398        items["rating_number"] = None399        items["price"] = None400    else:401        for cat in CATEGORIES:402            cache_path = RAW_DIR / f"meta_{cat}.jsonl"403            url = f"{BASE_URL}/raw/meta_categories/meta_{cat}.jsonl"404            cat_asins = set(reviews.loc[reviews["domain"] == cat, "parent_asin"])405            log.info(f"[{cat}] metadata → {cache_path.name} (target {len(cat_asins):,} items)")406            stream_filter_to_cache(url, cache_path, cat_asins,407                                   max_scan=args.meta_scan_cap)408 409        all_items = []410        for cat in CATEGORIES:411            cache_path = RAW_DIR / f"meta_{cat}.jsonl"412            df = load_meta_from_cache(cache_path, cat)413            log.info(f"[{cat}] loaded {len(df):,} metadata rows from cache")414            all_items.append(df)415        items = pd.concat(all_items, ignore_index=True)416        if not items.empty:417            items = items.drop_duplicates(subset=["parent_asin"])418 419        # Fallback for items without metadata: use review title420        found = set(items["parent_asin"]) if not items.empty else set()421        missing = (reviews[~reviews["parent_asin"].isin(found)]422                   .groupby(["parent_asin", "domain"])423                   .agg(title=("title", "first"))424                   .reset_index())425        if not missing.empty:426            for col in ("description", "features", "categories"):427                missing[col] = ""428            for col in ("average_rating", "rating_number", "price"):429                missing[col] = None430            items = pd.concat([items, missing], ignore_index=True)431            log.info(f"Added {len(missing):,} items from review-title fallback")432 433    # ── Phase 4: write parquet outputs ──────────────────────────────────────434    log.info("=" * 70)435    log.info("PHASE 4: writing processed parquet files")436    log.info("=" * 70)437    user_stats = build_user_stats(reviews[reviews["split"] == "train"])438    items = normalize_items_for_parquet(items)439 440    reviews.to_parquet(PROCESSED_DIR / "reviews.parquet", index=False)441    items.to_parquet(PROCESSED_DIR / "items.parquet", index=False)442    user_stats.to_parquet(PROCESSED_DIR / "users.parquet", index=False)443    log.info(f"Wrote processed files to {PROCESSED_DIR}/")444    log.info(f"  reviews.parquet: {len(reviews):,} rows")445    log.info(f"  items.parquet:   {len(items):,} rows")446    log.info(f"  users.parquet:   {len(user_stats):,} rows")447    log.info("Done.")448 449 450if __name__ == "__main__":451    main()452