| """Fase 1.4: ensambla el catalogo final y las secuencias de entrenamiento. |
| |
| Uso: uv run python scripts/build_catalog.py |
| Entrada: data/interim/vocab.parquet + data/interim/tmdb.jsonl + data/raw/ml-32m/ |
| Salida: data/catalog.sqlite + data/sequences.parquet |
| """ |
|
|
| import json |
| import sqlite3 |
| import sys |
| from pathlib import Path |
|
|
| import duckdb |
|
|
| ROOT = Path(__file__).resolve().parent.parent |
| INTERIM = ROOT / "data" / "interim" |
| RAW = ROOT / "data" / "raw" |
| MIN_TMDB_COVERAGE = 0.80 |
|
|
| vocab = duckdb.sql( |
| f"SELECT * FROM read_parquet('{(INTERIM / 'vocab.parquet').as_posix()}') ORDER BY numVotes DESC" |
| ).fetchall() |
| cols = [ |
| "tconst", "titleType", "primaryTitle", "originalTitle", "startYear", |
| "runtimeMinutes", "genres", "averageRating", "numVotes", |
| "directors", "cast", "tmdb_id_links", |
| ] |
|
|
| tmdb: dict[str, dict] = {} |
| tmdb_path = INTERIM / "tmdb.jsonl" |
| if tmdb_path.exists(): |
| with tmdb_path.open(encoding="utf-8") as f: |
| for line in f: |
| if line.strip(): |
| rec = json.loads(line) |
| if "error" not in rec: |
| tmdb[rec["tconst"]] = rec |
|
|
| coverage = len([1 for row in vocab if row[0] in tmdb]) / len(vocab) |
| if coverage < MIN_TMDB_COVERAGE: |
| sys.exit( |
| f"cobertura TMDB insuficiente: {coverage:.1%} del vocabulario " |
| f"(minimo {MIN_TMDB_COVERAGE:.0%}). Lanza scripts/fetch_tmdb.py hasta completar." |
| ) |
|
|
| db_path = ROOT / "data" / "catalog.sqlite" |
| db_path.unlink(missing_ok=True) |
| db = sqlite3.connect(db_path) |
| db.execute(""" |
| CREATE TABLE items ( |
| tconst TEXT PRIMARY KEY, |
| title_type TEXT NOT NULL, |
| primary_title TEXT NOT NULL, |
| original_title TEXT, |
| start_year INTEGER, |
| runtime_minutes INTEGER, |
| genres TEXT, |
| imdb_rating REAL, |
| num_votes INTEGER NOT NULL, |
| directors TEXT, |
| "cast" TEXT, |
| tmdb_id INTEGER, |
| media_type TEXT, |
| original_language TEXT, |
| countries TEXT, |
| budget INTEGER, |
| keywords TEXT, |
| poster_path TEXT, |
| popularity REAL |
| ) |
| """) |
|
|
| rows = [] |
| for row in vocab: |
| r = dict(zip(cols, row)) |
| t = tmdb.get(r["tconst"], {}) |
| rows.append(( |
| r["tconst"], r["titleType"], r["primaryTitle"], r["originalTitle"], |
| r["startYear"], r["runtimeMinutes"], r["genres"], r["averageRating"], |
| r["numVotes"], r["directors"], r["cast"], |
| t.get("tmdb_id"), t.get("media_type"), t.get("original_language"), |
| "|".join(t.get("production_countries") or []) or None, |
| t.get("budget") or None, |
| "|".join(t.get("keywords") or []) or None, |
| t.get("poster_path"), t.get("popularity"), |
| )) |
| db.executemany(f"INSERT INTO items VALUES ({','.join('?' * 19)})", rows) |
| db.execute("CREATE INDEX idx_items_title ON items (primary_title)") |
| db.commit() |
| db.close() |
| print(f"catalog.sqlite: {len(rows):,} items (cobertura TMDB {coverage:.1%})") |
|
|
| seq_out = ROOT / "data" / "sequences.parquet" |
| duckdb.sql(f""" |
| COPY ( |
| SELECT m.userId, v.tconst, m.rating, m.timestamp |
| FROM read_csv_auto('{(RAW / "ml-32m" / "ratings.csv").as_posix()}') m |
| JOIN read_csv_auto('{(RAW / "ml-32m" / "links.csv").as_posix()}') l USING (movieId) |
| JOIN read_parquet('{(INTERIM / "vocab.parquet").as_posix()}') v |
| ON v.tconst = printf('tt%07d', CAST(l.imdbId AS BIGINT)) |
| ORDER BY m.userId, m.timestamp |
| ) TO '{seq_out.as_posix()}' (FORMAT PARQUET) |
| """) |
| n, users = duckdb.sql( |
| f"SELECT count(*), count(DISTINCT userId) FROM read_parquet('{seq_out.as_posix()}')" |
| ).fetchone() |
| print(f"sequences.parquet: {n:,} interacciones de {users:,} usuarios") |
|
|