mutvar / scripts /join_rasp_ddg.py
edohollou
Fix disk space management in join_rasp_ddg: skip re-extraction, delete early
faf5d0e
Raw
History Blame Contribute Delete
13.3 kB
#!/usr/bin/env python3
"""
Download the RaSP proteome-wide ΔΔG dataset (Blaabjerg et al., eLife 2023)
and upload it as a partitioned HF dataset table (ddg/).
Source: https://sid.erda.dk/sharelink/fFPJWflLeE
File: rasp_preds_alphafold_UP000005640_9606_HUMAN_v2_vaex_dataframe.zip
~230M variants, 23 391 human proteins, AlphaFold2 structures, kcal/mol
Strategy
--------
1. Download the Vaex bulk file from ERDA (~8.6 GB zip)
2. Convert Vaex (HDF5) -> flat Parquet using vaex
3. DuckDB COPY partitions by protein_id (UniProt extracted from pdbid)
4. upload_large_folder -> ddg/ on HF (same pattern as esm1b/)
5. Backend JOIN: COALESCE(variants.pred_ddg, ddg.pred_ddg)
-> ESM-IF1 values kept for 13 enriched proteins, RaSP for all others
Column mapping
--------------
RaSP column -> MutVar column
pdbid -> protein_id (AF-{UNIPROT}-F1-... -> extract UniProt)
variant -> mutation_code (e.g. "V600E" β€” exact match)
score_ml -> pred_ddg (kcal/mol, positive = destabilising)
Threshold: pred_ddg > 1.5 kcal/mol -> destabilising (same as ESM-IF1 proxy)
Estimated runtime
-----------------
Download : ~30-60 min (8.6 GB from ERDA)
Vaex convert : ~10-20 min (HDF5 -> flat parquet)
DuckDB COPY : ~10-20 min (partition 230M rows)
upload_large : ~1-3h (chunked, resumable)
Total : ~3-5h
Resume behaviour
----------------
Output dir defaults to ~/mutvar_rasp_upload/
Partition step is skipped if ddg/ subfolder already has parquet files.
Upload is resumable (upload_large_folder uses .cache/ for state).
Usage
-----
pip install h5py duckdb huggingface_hub pyarrow
huggingface-cli login
python scripts/join_rasp_ddg.py [--dry-run] [--output-dir PATH]
"""
import os, re, argparse, time, zipfile
from pathlib import Path
import duckdb
HF_DATASET = os.environ.get("HF_DATASET", "edohollou/mutvar-variants")
ERDA_BASE = "https://sid.erda.dk/share_redirect/fFPJWflLeE"
VAEX_FILENAME = "rasp_preds_alphafold_UP000005640_9606_HUMAN_v2_vaex_dataframe.zip"
DDG_THRESHOLD = 1.5 # kcal/mol β€” destabilising threshold (Blaabjerg et al.)
DEFAULT_OUT_DIR = Path.home() / "mutvar_rasp_upload"
# ── Step 1: Download ───────────────────────────────────────────────────────────
def download_vaex(out_dir: Path) -> Path:
"""Download the Vaex zip from ERDA if not already present."""
zip_path = out_dir / VAEX_FILENAME
if zip_path.exists():
print(f"[1/4] Vaex zip already downloaded: {zip_path}")
return zip_path
import urllib.request
url = f"{ERDA_BASE}/{VAEX_FILENAME}"
out_dir.mkdir(parents=True, exist_ok=True)
print(f"[1/4] Downloading RaSP Vaex file (~8.6 GB)...")
print(f" {url}")
print(f" -> {zip_path}")
t0 = time.time()
def _progress(count, block, total):
pct = min(100, count * block * 100 // total)
done = count * block / 1e9
tot = total / 1e9
print(f"\r {done:.2f} / {tot:.2f} GB ({pct}%)", end="", flush=True)
urllib.request.urlretrieve(url, zip_path, reporthook=_progress)
print(f"\n Done in {(time.time()-t0)/60:.1f} min")
return zip_path
# ── Step 2: Vaex -> flat Parquet ───────────────────────────────────────────────
def _hdf5_is_valid(path: Path) -> bool:
"""Quick integrity check β€” opens the HDF5 and reads the row count."""
try:
import h5py
with h5py.File(str(path), "r") as f:
for col_path in [
"table/columns/pdbid/data",
"columns/pdbid/data",
"pdbid",
]:
if col_path in f:
_ = len(f[col_path])
return True
return False
except Exception:
return False
def vaex_to_parquet(zip_path: Path, flat_parquet: Path) -> Path:
"""
Unzip the Vaex HDF5 and convert to a flat Parquet using h5py.
Reads in chunks of 5M rows to avoid OOM.
Space management:
- Skips extraction if a valid HDF5 already exists
- Deletes the zip immediately after successful extraction
- Deletes the HDF5 immediately after the parquet is written
"""
if flat_parquet.exists():
size_gb = flat_parquet.stat().st_size / 1e9
print(f"[2/4] Flat parquet already exists ({size_gb:.2f} GB): {flat_parquet}")
return flat_parquet
import h5py
import pyarrow as pa
import pyarrow.parquet as pq
# ── Find or extract HDF5 ──────────────────────────────────────────────────
# Determine expected HDF5 path from zip contents without extracting
with zipfile.ZipFile(zip_path, "r") as zf:
names = zf.namelist()
hdf5_files = [n for n in names if ".hdf5" in n or n.endswith(".h5")]
if not hdf5_files:
raise RuntimeError(f"No HDF5 in zip. Contents: {names[:20]}")
hdf5_name = hdf5_files[0]
hdf5_path = flat_parquet.parent / Path(hdf5_name).name
if hdf5_path.exists() and _hdf5_is_valid(hdf5_path):
print(f"[2/4] HDF5 already extracted and valid β€” skipping unzip.")
print(f" {hdf5_path}")
else:
# Remove any partial/corrupt file before extracting
if hdf5_path.exists():
print(f" Removing corrupt/partial HDF5 ({hdf5_path.stat().st_size/1e9:.1f} GB)...")
hdf5_path.unlink()
print("[2/4] Extracting HDF5 from zip...")
t0 = time.time()
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extract(hdf5_name, flat_parquet.parent)
print(f" Extracted in {(time.time()-t0)/60:.1f} min "
f"({hdf5_path.stat().st_size/1e9:.1f} GB)")
# Free the zip immediately β€” no longer needed
try:
zip_path.unlink()
print(f" Deleted zip to reclaim {zip_path.stat().st_size/1e9:.1f} GB")
except Exception:
pass
# ── Convert HDF5 -> flat Parquet (chunked) ────────────────────────────────
print("[2/4] Converting HDF5 -> flat Parquet (chunked, h5py)...")
t0 = time.time()
_uid_re = re.compile(r"AF-([A-Z0-9]+)-F\d")
CHUNK = 5_000_000
with h5py.File(str(hdf5_path), "r") as f:
print(f" HDF5 top-level keys: {list(f.keys())}")
def _open_col(name: str):
for path in [
f"table/columns/{name}/data",
f"columns/{name}/data",
name,
]:
if path in f:
return f[path]
raise KeyError(f"Column '{name}' not found. Available: {list(f.keys())}")
col_pdbid = _open_col("pdbid")
col_variant = _open_col("variant")
col_score = _open_col("score_ml")
n_total = len(col_pdbid)
print(f" {n_total:,} rows total")
writer = None
for start in range(0, n_total, CHUNK):
end = min(start + CHUNK, n_total)
pdbids = col_pdbid[start:end].astype(str)
variants = col_variant[start:end].astype(str)
scores = col_score[start:end].astype("float32")
protein_ids = [
(m.group(1) if (m := _uid_re.search(p)) else p)
for p in pdbids
]
chunk = pa.table({
"protein_id": pa.array(protein_ids, type=pa.string()),
"mutation_code": pa.array(variants.tolist(), type=pa.string()),
"pred_ddg": pa.array(scores.tolist(), type=pa.float32()),
"pred_ddg_label": pa.array(
(scores > DDG_THRESHOLD).tolist(), type=pa.bool_()
),
})
if writer is None:
writer = pq.ParquetWriter(str(flat_parquet), chunk.schema)
writer.write_table(chunk)
pct = end * 100 // n_total
print(f"\r {end:,} / {n_total:,} rows ({pct}%)", end="", flush=True)
if writer:
writer.close()
print()
# Delete HDF5 immediately β€” frees ~105 GB before DuckDB partitioning
try:
size = hdf5_path.stat().st_size / 1e9
hdf5_path.unlink()
print(f" Deleted HDF5 ({size:.1f} GB freed)")
except Exception:
pass
size_gb = flat_parquet.stat().st_size / 1e9
print(f" Flat parquet: {size_gb:.2f} GB in {(time.time()-t0)/60:.1f} min")
return flat_parquet
# ── Step 3: DuckDB partition ───────────────────────────────────────────────────
def partition_locally(flat_parquet: Path, ddg_dir: Path, dry_run: bool) -> int:
"""
DuckDB reads the flat parquet and writes per-protein partitions.
Skipped if ddg_dir already contains parquet files.
"""
existing = list(ddg_dir.rglob("*.parquet"))
if existing and not dry_run:
print(f"[3/4] Partition dir already exists ({len(existing):,} files) β€” skipping.")
return len(existing)
con = duckdb.connect()
ddg_dir.mkdir(parents=True, exist_ok=True)
if dry_run:
print("[3/4] [dry-run] Partitioning 5-protein sample...")
sample = con.execute(f"""
SELECT DISTINCT protein_id FROM read_parquet('{flat_parquet}')
ORDER BY protein_id LIMIT 5
""").fetchall()
sample_ids = [r[0] for r in sample]
pid_list = ", ".join(f"'{p}'" for p in sample_ids)
print(f" Sample: {sample_ids}")
con.execute(f"""
COPY (
SELECT protein_id, mutation_code,
ROUND(pred_ddg, 4) AS pred_ddg,
pred_ddg_label
FROM read_parquet('{flat_parquet}')
WHERE protein_id IN ({pid_list})
)
TO '{ddg_dir}'
(FORMAT PARQUET, PARTITION_BY (protein_id), OVERWRITE_OR_IGNORE true)
""")
else:
print("[3/4] Partitioning with DuckDB (~10-20 min)...")
t0 = time.time()
con.execute(f"""
COPY (
SELECT protein_id, mutation_code,
ROUND(pred_ddg, 4) AS pred_ddg,
pred_ddg_label
FROM read_parquet('{flat_parquet}')
)
TO '{ddg_dir}'
(FORMAT PARQUET, PARTITION_BY (protein_id), OVERWRITE_OR_IGNORE true)
""")
print(f" Done in {(time.time()-t0)/60:.1f} min")
n = len(list(ddg_dir.rglob("*.parquet")))
print(f" {n:,} parquet files in {ddg_dir}")
con.close()
return n
# ── Step 4: Upload ─────────────────────────────────────────────────────────────
def upload(upload_root: Path, dry_run: bool):
if dry_run:
print("[4/4] [dry-run] Skipping upload. Sample files:")
import pandas as pd
ddg_dir = upload_root / "ddg"
for f in sorted(ddg_dir.rglob("*.parquet"))[:5]:
df = pd.read_parquet(f)
print(f" {f.parent.name}/{f.name}: {len(df)} rows, "
f"pred_ddg sample={df['pred_ddg'].head(3).tolist()}")
return
from huggingface_hub import HfApi
api = HfApi()
print("[4/4] Uploading ddg/ to HuggingFace (upload_large_folder, resumable)...")
print(" Safe to Ctrl-C and re-run β€” upload resumes from last checkpoint.")
t0 = time.time()
api.upload_large_folder(
folder_path=str(upload_root),
repo_id=HF_DATASET,
repo_type="dataset",
)
print(f" Done in {(time.time()-t0)/60:.1f} min")
print()
print("[done] Next steps:")
print(" 1. git push -> Space picks up backend/main.py JOIN for ddg/")
print(f" 2. Delete local files: rm -rf {upload_root}")
# ── Main ───────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true",
help="Process 5 proteins only, skip upload")
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUT_DIR,
help=f"Persistent output dir (default: {DEFAULT_OUT_DIR})")
args = parser.parse_args()
upload_root = args.output_dir
ddg_dir = upload_root / "ddg"
flat_parquet = upload_root / "rasp_flat.parquet"
print(f" Upload root : {upload_root}")
print(f" Parquet dir : {ddg_dir}\n")
zip_path = download_vaex(upload_root)
vaex_to_parquet(zip_path, flat_parquet)
n = partition_locally(flat_parquet, ddg_dir, dry_run=args.dry_run)
if n > 0:
upload(upload_root, dry_run=args.dry_run)
if __name__ == "__main__":
main()