| |
| """ |
| MMseqs2 cluster-based train/test split from CSV (highFRET / noFRET). |
| |
| Convention (Bushuiev & Bushuiev-style): cluster all sequences at a fixed identity |
| threshold, rank clusters by how distant they are from the rest of the dataset, then |
| hold out entire clusters for test. No sequence in a test cluster appears in train, |
| which controls leakage across both classes. |
| |
| Pipeline: |
| 1. Read CSV (variant, Protein, label). |
| 2. MMseqs2 easy-cluster on all sequences (--min-seq-id / coverage). |
| 3. All-against-all MMseqs2 search for pairwise identities. |
| 4. Per cluster: mean sequence identity to sequences *outside* that cluster (global). |
| 5. Assign the most distant clusters to test (whole clusters only). |
| Default: hold out --test-fraction of all clusters by count (e.g. 20% of clusters). |
| Optional --holdout-by sequences: add distant clusters until ~that fraction of sequences. |
| |
| Also reports cross-class similarity (test vs train, other label) for rebuttal text. |
| |
| Primary outputs: <prefix>_train.csv and <prefix>_test.csv in FRET selection layout |
| (Unnamed: 0, variant, DNA, Protein, TPM columns, label — no split column). |
| Analysis/debug: <prefix>_with_split.csv, clustering stats, cluster summary. |
| |
| Outputs include clustering diagnostics (*_clustering_stats.txt / .csv) to verify |
| whether clusters are mostly singletons (degenerate cluster split). |
| |
| Usage: |
| python mmseqs_cluster_split.py <input.csv> \\ |
| [--cluster-identity 0.8] [--test-fraction 0.2] |
| """ |
|
|
| import argparse |
| import os |
| import sys |
| import tempfile |
| import shutil |
| from pathlib import Path |
|
|
| import pandas as pd |
| import subprocess |
|
|
|
|
| def run_mmseqs(cmd: list[str]) -> None: |
| """Run an MMseqs2 command; print stderr/stdout and exit on failure.""" |
| result = subprocess.run(cmd, capture_output=True, text=True) |
| if result.returncode != 0: |
| print(f"ERROR: MMseqs2 failed: {' '.join(cmd)}", file=sys.stderr) |
| if result.stderr: |
| print(result.stderr, file=sys.stderr) |
| if result.stdout: |
| print(result.stdout, file=sys.stderr) |
| sys.exit(result.returncode) |
|
|
|
|
| def check_mmseqs_available() -> str: |
| """Ensure `mmseqs` is on PATH; return version string for logging.""" |
| if shutil.which("mmseqs") is None: |
| print( |
| "ERROR: MMseqs2 not found on PATH.\n" |
| " conda: conda install -c bioconda mmseqs2\n" |
| " HPC: module load <mmseqs2-module> (site-specific)\n" |
| " verify: which mmseqs && mmseqs version", |
| file=sys.stderr, |
| ) |
| sys.exit(1) |
| result = subprocess.run( |
| ["mmseqs", "version"], |
| capture_output=True, |
| text=True, |
| ) |
| version = (result.stdout or result.stderr).strip().splitlines()[0] if result.returncode == 0 else "unknown" |
| return version |
|
|
|
|
| LABEL_HIGH = "highFRET" |
| LABEL_NO = "noFRET" |
|
|
| |
| FRET_EXPORT_COLUMNS = [ |
| "variant", |
| "DNA", |
| "Protein", |
| "R0_TPM", |
| "R1F_TPM", |
| "R2F_TPM", |
| "R3F_TPM", |
| "R1N_TPM", |
| "R2N_TPM", |
| "label", |
| ] |
|
|
| _LABEL_ALIASES = { |
| "highfret": LABEL_HIGH, |
| "high fret": LABEL_HIGH, |
| "no fret": LABEL_NO, |
| "nofret": LABEL_NO, |
| "no_fret": LABEL_NO, |
| "lowfret": LABEL_NO, |
| "low fret": LABEL_NO, |
| "low_fret": LABEL_NO, |
| } |
|
|
|
|
| def _normalize_label(raw: str) -> str | None: |
| key = " ".join(str(raw).strip().lower().split()) |
| return _LABEL_ALIASES.get(key) |
|
|
|
|
| def load_csv(csv_path: str) -> pd.DataFrame: |
| df = pd.read_csv(csv_path) |
| for col in ("variant", "Protein", "label"): |
| if col not in df.columns: |
| raise ValueError(f"CSV must have column '{col}'. Found: {list(df.columns)}") |
| df["variant"] = df["variant"].astype(str).str.strip() |
| df["Protein"] = df["Protein"].astype(str).str.strip() |
| df["label"] = df["label"].astype(str).str.strip() |
| return df |
|
|
|
|
| def filter_labels(df: pd.DataFrame) -> pd.DataFrame: |
| canonical = [] |
| for raw in df["label"]: |
| c = _normalize_label(str(raw)) |
| canonical.append(c) |
| df = df.copy() |
| df["label_original"] = df["label"].astype(str).str.strip() |
| df["label"] = canonical |
| out = df[df["label"].notna()].copy() |
| out["label"] = out["label"].astype(str) |
| dropped = len(df) - len(out) |
| if dropped: |
| print( |
| f" Dropped {dropped} rows with unrecognized labels " |
| "(accepted: highFRET, noFRET, no FRET, lowFRET, etc.)" |
| ) |
| return out.reset_index(drop=True) |
|
|
|
|
| def csv_to_fasta(df: pd.DataFrame, fasta_path: str, id_col: str = "variant", seq_col: str = "Protein") -> None: |
| with open(fasta_path, "w") as f: |
| for _, row in df.iterrows(): |
| seq_id = str(row[id_col]).replace(" ", "_") |
| seq = str(row[seq_col]) |
| if not seq: |
| continue |
| f.write(f">{seq_id}\n{seq}\n") |
| print(f" Wrote {len(df)} sequences to {fasta_path}") |
|
|
|
|
| def perform_mmseqs_easy_cluster( |
| fasta_file: str, |
| tmp_dir: str, |
| identity: float, |
| coverage: float, |
| threads: int, |
| evalue: float, |
| ) -> str: |
| """Run MMseqs2 easy-cluster; return path to *_cluster.tsv.""" |
| print( |
| f"Running MMseqs2 easy-cluster (min-seq-id={identity}, coverage={coverage})..." |
| ) |
| cluster_prefix = os.path.join(tmp_dir, "cluster") |
| mmseqs_tmp = os.path.join(tmp_dir, "mmseqs_cluster_tmp") |
| os.makedirs(mmseqs_tmp, exist_ok=True) |
|
|
| cmd = [ |
| "mmseqs", |
| "easy-cluster", |
| fasta_file, |
| cluster_prefix, |
| mmseqs_tmp, |
| "--min-seq-id", |
| str(identity), |
| "-c", |
| str(coverage), |
| "--cov-mode", |
| "0", |
| "-e", |
| str(evalue), |
| "--threads", |
| str(threads), |
| "--compressed", |
| "1", |
| "--remove-tmp-files", |
| "1", |
| "--split", |
| "0", |
| "--db-load-mode", |
| "1", |
| ] |
| run_mmseqs(cmd) |
|
|
| cluster_file = f"{cluster_prefix}_cluster.tsv" |
| if not os.path.exists(cluster_file): |
| raise FileNotFoundError(f"Cluster file not found: {cluster_file}") |
| print(f" Clustering finished: {cluster_file}") |
| return cluster_file |
|
|
|
|
| def parse_cluster_assignments(cluster_file: str) -> tuple[dict[str, str], dict[str, set[str]]]: |
| """ |
| Parse MMseqs2 cluster TSV (rep_id, member_id). |
| |
| Returns: |
| member_to_rep: member_id -> representative cluster id |
| cluster_members: rep_id -> set of member ids |
| """ |
| member_to_rep: dict[str, str] = {} |
| cluster_members: dict[str, set[str]] = {} |
|
|
| with open(cluster_file) as f: |
| for line in f: |
| line = line.strip() |
| if not line: |
| continue |
| parts = line.split("\t") |
| if len(parts) < 2: |
| continue |
| rep_id, member_id = parts[0], parts[1] |
| member_to_rep[member_id] = rep_id |
| cluster_members.setdefault(rep_id, set()).add(member_id) |
|
|
| print(f" Parsed {len(cluster_members)} clusters, {len(member_to_rep)} sequence assignments") |
| return member_to_rep, cluster_members |
|
|
|
|
| def compute_clustering_stats( |
| cluster_members: dict[str, set[str]], |
| df: pd.DataFrame, |
| cluster_identity: float, |
| cluster_coverage: float, |
| ) -> tuple[dict[str, object], list[str]]: |
| """ |
| Summarize cluster size distribution (detect all-singleton clustering). |
| |
| Returns (stats_dict, report_lines for printing / text file). |
| """ |
| sizes = [len(m) for m in cluster_members.values()] |
| n_clusters = len(sizes) |
| n_sequences = int(sum(sizes)) |
| n_singleton_clusters = sum(1 for s in sizes if s == 1) |
| seq_in_singletons = sum(s for s in sizes if s == 1) |
| multi_sizes = [s for s in sizes if s > 1] |
| n_multi_clusters = len(multi_sizes) |
| seq_in_multi = sum(multi_sizes) |
|
|
| def _pct(n: int, d: int) -> float: |
| return 100.0 * n / d if d else 0.0 |
|
|
| size_series = pd.Series(sizes) if sizes else pd.Series(dtype=float) |
| buckets = { |
| "size_1": sum(1 for s in sizes if s == 1), |
| "size_2": sum(1 for s in sizes if s == 2), |
| "size_3_5": sum(1 for s in sizes if 3 <= s <= 5), |
| "size_6_10": sum(1 for s in sizes if 6 <= s <= 10), |
| "size_11_plus": sum(1 for s in sizes if s >= 11), |
| } |
|
|
| largest_rep = max(cluster_members, key=lambda r: len(cluster_members[r])) if cluster_members else "" |
| largest_size = len(cluster_members[largest_rep]) if largest_rep else 0 |
|
|
| |
| multi_mixed_label = 0 |
| multi_pure_high = 0 |
| multi_pure_no = 0 |
| if "variant_key" in df.columns and "label" in df.columns: |
| for rep_id, members in cluster_members.items(): |
| if len(members) < 2: |
| continue |
| labels = df.loc[df["variant_key"].isin(members), "label"].unique() |
| if len(labels) > 1: |
| multi_mixed_label += 1 |
| elif LABEL_HIGH in labels: |
| multi_pure_high += 1 |
| elif LABEL_NO in labels: |
| multi_pure_no += 1 |
|
|
| all_singleton = n_singleton_clusters == n_clusters and n_clusters > 0 |
| degenerate_split = all_singleton or n_multi_clusters == 0 |
|
|
| stats: dict[str, object] = { |
| "n_sequences": n_sequences, |
| "n_clusters": n_clusters, |
| "compression_ratio_seq_per_cluster": n_sequences / n_clusters if n_clusters else 0.0, |
| "cluster_identity_threshold": cluster_identity, |
| "cluster_coverage_threshold": cluster_coverage, |
| "n_singleton_clusters": n_singleton_clusters, |
| "pct_singleton_clusters": _pct(n_singleton_clusters, n_clusters), |
| "n_sequences_in_singleton_clusters": seq_in_singletons, |
| "pct_sequences_in_singleton_clusters": _pct(seq_in_singletons, n_sequences), |
| "n_multi_member_clusters": n_multi_clusters, |
| "pct_multi_member_clusters": _pct(n_multi_clusters, n_clusters), |
| "n_sequences_in_multi_member_clusters": seq_in_multi, |
| "pct_sequences_in_multi_member_clusters": _pct(seq_in_multi, n_sequences), |
| "cluster_size_min": int(size_series.min()) if len(size_series) else 0, |
| "cluster_size_max": largest_size, |
| "cluster_size_mean": float(size_series.mean()) if len(size_series) else 0.0, |
| "cluster_size_median": float(size_series.median()) if len(size_series) else 0.0, |
| "cluster_size_std": float(size_series.std()) if len(size_series) > 1 else 0.0, |
| "largest_cluster_id": largest_rep, |
| "largest_cluster_n_members": largest_size, |
| "bucket_clusters_size_1": buckets["size_1"], |
| "bucket_clusters_size_2": buckets["size_2"], |
| "bucket_clusters_size_3_5": buckets["size_3_5"], |
| "bucket_clusters_size_6_10": buckets["size_6_10"], |
| "bucket_clusters_size_11_plus": buckets["size_11_plus"], |
| "multi_member_clusters_mixed_label": multi_mixed_label, |
| "multi_member_clusters_pure_highFRET": multi_pure_high, |
| "multi_member_clusters_pure_noFRET": multi_pure_no, |
| "all_clusters_singleton": all_singleton, |
| "cluster_split_degenerate": degenerate_split, |
| } |
|
|
| lines = [ |
| "=" * 60, |
| "MMseqs2 clustering statistics", |
| "=" * 60, |
| f"Parameters: min-seq-id={cluster_identity}, coverage={cluster_coverage}", |
| "", |
| f"Sequences: {n_sequences}", |
| f"Clusters: {n_clusters}", |
| f"Compression (seq/cluster): {stats['compression_ratio_seq_per_cluster']:.3f}", |
| "", |
| "Cluster size distribution", |
| f" min / median / mean / max: " |
| f"{stats['cluster_size_min']} / {stats['cluster_size_median']:.2f} / " |
| f"{stats['cluster_size_mean']:.2f} / {stats['cluster_size_max']}", |
| f" std dev: {stats['cluster_size_std']:.2f}", |
| "", |
| "Singleton clusters (size = 1)", |
| f" clusters: {n_singleton_clusters} / {n_clusters} " |
| f"({stats['pct_singleton_clusters']:.1f}%)", |
| f" sequences: {seq_in_singletons} / {n_sequences} " |
| f"({stats['pct_sequences_in_singleton_clusters']:.1f}%)", |
| "", |
| "Multi-member clusters (size > 1)", |
| f" clusters: {n_multi_clusters} / {n_clusters} " |
| f"({stats['pct_multi_member_clusters']:.1f}%)", |
| f" sequences: {seq_in_multi} / {n_sequences} " |
| f"({stats['pct_sequences_in_multi_member_clusters']:.1f}%)", |
| f" mixed label (highFRET + noFRET): {multi_mixed_label}", |
| f" pure highFRET only: {multi_pure_high}", |
| f" pure noFRET only: {multi_pure_no}", |
| "", |
| "Cluster count by size bucket", |
| f" size 1: {buckets['size_1']}", |
| f" size 2: {buckets['size_2']}", |
| f" size 3-5: {buckets['size_3_5']}", |
| f" size 6-10: {buckets['size_6_10']}", |
| f" size 11+: {buckets['size_11_plus']}", |
| "", |
| f"Largest cluster: id={largest_rep!r}, n_members={largest_size}", |
| ] |
|
|
| if all_singleton: |
| lines.extend( |
| [ |
| "", |
| "WARNING: Every cluster has exactly one sequence.", |
| " Cluster-based hold-out is equivalent to holding out individual sequences.", |
| " Consider lowering --cluster-identity / --cluster-coverage, or check", |
| " sequence diversity and FASTA IDs (duplicates / parsing).", |
| ] |
| ) |
| elif degenerate_split: |
| lines.extend( |
| [ |
| "", |
| "WARNING: No multi-member clusters; cluster split has no within-cluster groups.", |
| ] |
| ) |
|
|
| lines.append("=" * 60) |
| return stats, lines |
|
|
|
|
| def save_clustering_stats( |
| stats: dict[str, object], |
| report_lines: list[str], |
| txt_path: str, |
| csv_path: str, |
| ) -> None: |
| """Write human-readable report and one-row-per-metric CSV.""" |
| with open(txt_path, "w") as f: |
| f.write("\n".join(report_lines) + "\n") |
| pd.DataFrame([stats]).to_csv(csv_path, index=False) |
|
|
|
|
| def perform_all_vs_all_mmseqs_search( |
| fasta_file: str, |
| tmp_dir: str, |
| sensitivity: float = 7.5, |
| threads: int = 1, |
| max_seqs: int = 100, |
| evalue: float = 0.001, |
| ) -> str: |
| print("Running all-against-all MMseqs2 search...") |
| query_db = os.path.join(tmp_dir, "query_db") |
| target_db = query_db |
| results_db = os.path.join(tmp_dir, "results_db") |
| results_file = os.path.join(tmp_dir, "mmseqs_all_vs_all.tsv") |
| mmseqs_tmp = os.path.join(tmp_dir, "mmseqs_search_tmp") |
| os.makedirs(mmseqs_tmp, exist_ok=True) |
|
|
| run_mmseqs(["mmseqs", "createdb", fasta_file, query_db]) |
| run_mmseqs( |
| [ |
| "mmseqs", |
| "search", |
| query_db, |
| target_db, |
| results_db, |
| mmseqs_tmp, |
| "--threads", |
| str(threads), |
| "-s", |
| str(sensitivity), |
| "--max-seqs", |
| str(max_seqs), |
| "-e", |
| str(evalue), |
| "--compressed", |
| "1", |
| "--remove-tmp-files", |
| "1", |
| ] |
| ) |
| run_mmseqs( |
| ["mmseqs", "convertalis", query_db, target_db, results_db, results_file] |
| ) |
| print(" MMseqs2 search finished.") |
| return results_file |
|
|
|
|
| def parse_mmseqs_results(results_file: str) -> pd.DataFrame: |
| return pd.read_csv( |
| results_file, |
| sep="\t", |
| header=None, |
| names=[ |
| "query", |
| "target", |
| "seqid", |
| "alnlen", |
| "mismatch", |
| "gapopen", |
| "qstart", |
| "qend", |
| "tstart", |
| "tend", |
| "evalue", |
| "bits", |
| "qcov", |
| "tcov", |
| ], |
| ) |
|
|
|
|
| def compute_cluster_external_similarity( |
| search_results: pd.DataFrame, |
| cluster_members: dict[str, set[str]], |
| all_ids: set[str], |
| ) -> dict[str, float]: |
| """ |
| For each cluster, mean seqid from cluster members to sequences outside the cluster. |
| Lower = more distant cluster (better candidate for test hold-out). |
| """ |
| member_to_rep: dict[str, str] = {} |
| for rep_id, members in cluster_members.items(): |
| for m in members: |
| member_to_rep[m] = rep_id |
|
|
| external_hits: dict[str, list[float]] = {rep: [] for rep in cluster_members} |
|
|
| for _, row in search_results.iterrows(): |
| q, t, seqid = row["query"], row["target"], float(row["seqid"]) |
| if q == t: |
| continue |
| if q not in all_ids or t not in all_ids: |
| continue |
| q_cluster = member_to_rep.get(q) |
| t_cluster = member_to_rep.get(t) |
| if q_cluster is None or t_cluster is None: |
| continue |
| if q_cluster == t_cluster: |
| continue |
| external_hits[q_cluster].append(seqid) |
|
|
| scores: dict[str, float] = {} |
| for rep_id in cluster_members: |
| hits = external_hits.get(rep_id, []) |
| scores[rep_id] = sum(hits) / len(hits) if hits else 0.0 |
| return scores |
|
|
|
|
| def select_test_clusters( |
| cluster_members: dict[str, set[str]], |
| cluster_scores: dict[str, float], |
| test_fraction: float, |
| holdout_by: str, |
| ) -> set[str]: |
| """ |
| Select whole clusters for test (most distant = lowest external mean seqid first). |
| |
| holdout_by: |
| clusters — top test_fraction of cluster count (default, Bushuiev-style) |
| sequences — greedy add clusters until ~test_fraction of sequences are in test |
| """ |
| ranked = sorted(cluster_scores.items(), key=lambda x: x[1]) |
| n_clusters = len(cluster_members) |
| n_seq = sum(len(m) for m in cluster_members.values()) |
|
|
| if holdout_by == "clusters": |
| n_take = max(1, int(round(test_fraction * n_clusters))) |
| if n_take >= n_clusters and n_clusters > 1: |
| n_take = n_clusters - 1 |
| return {rep_id for rep_id, _ in ranked[:n_take]} |
|
|
| target_n = max(1, int(round(test_fraction * n_seq))) |
| test_clusters: set[str] = set() |
| test_count = 0 |
| for rep_id, _score in ranked: |
| size = len(cluster_members[rep_id]) |
| if test_count >= target_n: |
| break |
| remaining_train = n_seq - test_count - size |
| if remaining_train < 1 and test_clusters: |
| break |
| test_clusters.add(rep_id) |
| test_count += size |
| if not test_clusters and ranked: |
| test_clusters.add(ranked[0][0]) |
| return test_clusters |
|
|
|
|
| def compute_cross_class_similarity_report( |
| search_results: pd.DataFrame, |
| df: pd.DataFrame, |
| test_ids: set[str], |
| ) -> pd.DataFrame: |
| """ |
| Per test sequence: max/mean seqid to train sequences in the *other* label. |
| """ |
| id_to_label = { |
| str(row["variant_key"]): row["label"] |
| for _, row in df.iterrows() |
| } |
| train_ids = {k for k in id_to_label if k not in test_ids} |
|
|
| other_label_hits: dict[str, list[float]] = {tid: [] for tid in test_ids} |
| same_label_train_hits: dict[str, list[float]] = {tid: [] for tid in test_ids} |
| any_train_hits: dict[str, list[float]] = {tid: [] for tid in test_ids} |
|
|
| for _, row in search_results.iterrows(): |
| q, t, seqid = row["query"], row["target"], float(row["seqid"]) |
| if q == t: |
| continue |
| if q not in test_ids or t not in train_ids: |
| continue |
| any_train_hits[q].append(seqid) |
| q_label = id_to_label.get(q) |
| t_label = id_to_label.get(t) |
| if q_label is None or t_label is None: |
| continue |
| if q_label != t_label: |
| other_label_hits[q].append(seqid) |
| else: |
| same_label_train_hits[q].append(seqid) |
|
|
| rows = [] |
| for tid in sorted(test_ids): |
| other = other_label_hits.get(tid, []) |
| same = same_label_train_hits.get(tid, []) |
| any_tr = any_train_hits.get(tid, []) |
| rows.append( |
| { |
| "variant_key": tid, |
| "label": id_to_label.get(tid, ""), |
| "max_seqid_to_other_label_train": max(other) if other else 0.0, |
| "mean_seqid_to_other_label_train": sum(other) / len(other) if other else 0.0, |
| "max_seqid_to_same_label_train": max(same) if same else 0.0, |
| "mean_seqid_to_same_label_train": sum(same) / len(same) if same else 0.0, |
| "max_seqid_to_any_train": max(any_tr) if any_tr else 0.0, |
| "mean_seqid_to_any_train": sum(any_tr) / len(any_tr) if any_tr else 0.0, |
| "n_hits_other_label_train": len(other), |
| "n_hits_same_label_train": len(same), |
| } |
| ) |
| return pd.DataFrame(rows) |
|
|
|
|
| def prepare_fret_export_df(df: pd.DataFrame) -> pd.DataFrame: |
| """Subset and order columns for FRET-style train/test CSVs (label = original input).""" |
| out = df.copy() |
| if "label_original" in out.columns: |
| out["label"] = out["label_original"] |
| cols = [c for c in FRET_EXPORT_COLUMNS if c in out.columns] |
| missing = [c for c in FRET_EXPORT_COLUMNS if c not in out.columns] |
| if missing: |
| print(f" WARNING: FRET export missing columns: {missing}") |
| return out[cols].reset_index(drop=True) |
|
|
|
|
| def write_fret_format_csv(df: pd.DataFrame, path: str) -> None: |
| """Write CSV with pandas index as 'Unnamed: 0' (matches FRET selection export).""" |
| prepare_fret_export_df(df).to_csv(path, index=True) |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser( |
| description="MMseqs2 cluster-based train/test split (hold out distant whole clusters)", |
| formatter_class=argparse.RawDescriptionHelpFormatter, |
| epilog=__doc__, |
| ) |
| parser.add_argument("input_csv", type=str, help="Input CSV: variant, Protein, label") |
| parser.add_argument( |
| "--output-prefix", |
| type=str, |
| default=None, |
| help="Prefix for output files (default: <input_stem>_cluster_split)", |
| ) |
| parser.add_argument( |
| "--output-dir", |
| type=str, |
| default=None, |
| help="Directory for output CSVs (created if missing)", |
| ) |
| parser.add_argument( |
| "--test-fraction", |
| type=float, |
| default=0.2, |
| help="Fraction for hold-out (default 0.2): clusters or sequences per --holdout-by", |
| ) |
| parser.add_argument( |
| "--holdout-by", |
| choices=("clusters", "sequences"), |
| default="clusters", |
| help="Hold out test_fraction of clusters (default) or of sequences via whole clusters", |
| ) |
| parser.add_argument( |
| "--cluster-identity", |
| type=float, |
| default=0.8, |
| help="MMseqs2 easy-cluster --min-seq-id (default 0.8)", |
| ) |
| parser.add_argument( |
| "--cluster-coverage", |
| type=float, |
| default=0.8, |
| help="MMseqs2 easy-cluster -c coverage (default 0.8)", |
| ) |
| parser.add_argument("--sensitivity", "-s", type=float, default=7.5, help="Search sensitivity") |
| parser.add_argument("--threads", "-t", type=int, default=1, help="MMseqs threads (0 = all CPUs)") |
| parser.add_argument("--max-seqs", type=int, default=100, help="Search --max-seqs") |
| parser.add_argument("--evalue", "-e", type=float, default=0.001, help="E-value for cluster and search") |
| parser.add_argument("--keep-temp", action="store_true", help="Keep temporary directory") |
| parser.add_argument("--tmp-dir", type=str, default=None, help="Temp directory for MMseqs") |
| args = parser.parse_args() |
|
|
| mmseqs_version = check_mmseqs_available() |
| print(f"MMseqs2 version: {mmseqs_version}") |
|
|
| if not os.path.exists(args.input_csv): |
| print(f"ERROR: Input CSV not found: {args.input_csv}", file=sys.stderr) |
| sys.exit(1) |
| if not 0 < args.test_fraction < 1: |
| print("ERROR: --test-fraction must be between 0 and 1", file=sys.stderr) |
| sys.exit(1) |
| if not 0 < args.cluster_identity <= 1: |
| print("ERROR: --cluster-identity must be in (0, 1]", file=sys.stderr) |
| sys.exit(1) |
|
|
| prefix = args.output_prefix or (Path(args.input_csv).stem + "_cluster_split") |
|
|
| if args.threads == 0: |
| import multiprocessing |
|
|
| args.threads = multiprocessing.cpu_count() |
| print(f"Using {args.threads} threads") |
|
|
| tmp_dir = args.tmp_dir |
| if tmp_dir: |
| tmp_dir = os.path.abspath(tmp_dir) |
| os.makedirs(tmp_dir, exist_ok=True) |
| else: |
| tmp_dir = tempfile.mkdtemp(prefix="mmseqs_csv_cluster_split_") |
| print(f"Temporary directory: {tmp_dir}") |
|
|
| try: |
| df = load_csv(args.input_csv) |
| df = filter_labels(df) |
| if df.empty: |
| print("ERROR: No rows with label highFRET or noFRET", file=sys.stderr) |
| sys.exit(1) |
| print(f"Loaded {len(df)} sequences ({df['label'].value_counts().to_dict()})") |
|
|
| df["variant_key"] = df["variant"].astype(str).str.replace(" ", "_", regex=False) |
| all_ids = set(df["variant_key"].astype(str)) |
|
|
| fasta_path = os.path.join(tmp_dir, "sequences.fasta") |
| csv_to_fasta(df, fasta_path) |
|
|
| cluster_file = perform_mmseqs_easy_cluster( |
| fasta_path, |
| tmp_dir, |
| identity=args.cluster_identity, |
| coverage=args.cluster_coverage, |
| threads=args.threads, |
| evalue=args.evalue, |
| ) |
| member_to_rep, cluster_members = parse_cluster_assignments(cluster_file) |
| df["cluster_id"] = df["variant_key"].map(member_to_rep) |
|
|
| cluster_stats, cluster_report_lines = compute_clustering_stats( |
| cluster_members, |
| df, |
| args.cluster_identity, |
| args.cluster_coverage, |
| ) |
| cluster_stats["mmseqs2_version"] = mmseqs_version |
| cluster_report_lines.insert(3, f"MMseqs2 version: {mmseqs_version}") |
| print("") |
| for line in cluster_report_lines: |
| print(line) |
|
|
| missing = df["cluster_id"].isna().sum() |
| if missing: |
| print(f" WARNING: {missing} sequences missing from cluster TSV", file=sys.stderr) |
|
|
| results_file = perform_all_vs_all_mmseqs_search( |
| fasta_path, |
| tmp_dir, |
| sensitivity=args.sensitivity, |
| threads=args.threads, |
| max_seqs=args.max_seqs, |
| evalue=args.evalue, |
| ) |
| search_results = parse_mmseqs_results(results_file) |
| print(f" Parsed {len(search_results)} MMseqs2 hits") |
|
|
| cluster_scores = compute_cluster_external_similarity( |
| search_results, cluster_members, all_ids |
| ) |
| test_cluster_ids = select_test_clusters( |
| cluster_members, |
| cluster_scores, |
| args.test_fraction, |
| args.holdout_by, |
| ) |
|
|
| test_member_ids: set[str] = set() |
| for rep_id in test_cluster_ids: |
| test_member_ids.update(cluster_members[rep_id]) |
|
|
| test_variants = set( |
| df.loc[df["variant_key"].isin(test_member_ids), "variant"].astype(str) |
| ) |
| df["split"] = df["variant"].apply(lambda v: "test" if v in test_variants else "train") |
| df["cluster_external_mean_seqid"] = df["cluster_id"].map(cluster_scores) |
|
|
| n_clusters = len(cluster_members) |
| n_test_clusters = len(test_cluster_ids) |
| print( |
| f" Test clusters: {n_test_clusters}/{n_clusters} " |
| f"({100 * n_test_clusters / n_clusters:.1f}% of clusters)" |
| ) |
| print( |
| f" Test sequences: {len(test_member_ids)}/{len(df)} " |
| f"({100 * len(test_member_ids) / len(df):.1f}% of sequences)" |
| ) |
| print(f" Hold-out mode: --holdout-by {args.holdout_by}") |
|
|
| if args.output_dir: |
| os.makedirs(args.output_dir, exist_ok=True) |
| base = Path(prefix).name |
| train_csv = os.path.join(args.output_dir, f"{base}_train.csv") |
| test_csv = os.path.join(args.output_dir, f"{base}_test.csv") |
| out_csv = os.path.join(args.output_dir, f"{base}_with_split.csv") |
| cross_csv = os.path.join(args.output_dir, f"{base}_cross_class_similarity.csv") |
| cluster_summary_csv = os.path.join(args.output_dir, f"{base}_cluster_summary.csv") |
| cluster_stats_txt = os.path.join(args.output_dir, f"{base}_clustering_stats.txt") |
| cluster_stats_csv = os.path.join(args.output_dir, f"{base}_clustering_stats.csv") |
| else: |
| train_csv = f"{prefix}_train.csv" |
| test_csv = f"{prefix}_test.csv" |
| out_csv = f"{prefix}_with_split.csv" |
| cross_csv = f"{prefix}_cross_class_similarity.csv" |
| cluster_summary_csv = f"{prefix}_cluster_summary.csv" |
| cluster_stats_txt = f"{prefix}_clustering_stats.txt" |
| cluster_stats_csv = f"{prefix}_clustering_stats.csv" |
|
|
| save_clustering_stats( |
| cluster_stats, cluster_report_lines, cluster_stats_txt, cluster_stats_csv |
| ) |
| print(f"Saved clustering statistics to: {cluster_stats_txt}") |
| print(f"Saved clustering statistics (CSV) to: {cluster_stats_csv}") |
|
|
| analysis_cols = [ |
| c |
| for c in df.columns |
| if c not in ("variant_key", "label_original") |
| ] |
| df[analysis_cols].to_csv(out_csv, index=False) |
| print(f"Saved analysis table (split, cluster_id, …) to: {out_csv}") |
|
|
| train_df = df[df["split"] == "train"] |
| test_df = df[df["split"] == "test"] |
| write_fret_format_csv(train_df, train_csv) |
| print(f"Saved train set ({len(train_df)} rows, FRET format) to: {train_csv}") |
| write_fret_format_csv(test_df, test_csv) |
| print(f"Saved test set ({len(test_df)} rows, FRET format) to: {test_csv}") |
|
|
| cross_df = compute_cross_class_similarity_report( |
| search_results, df, test_member_ids |
| ) |
| cross_df.to_csv(cross_csv, index=False) |
| print(f"Saved per-test cross-class similarity to: {cross_csv}") |
|
|
| cluster_rows = [] |
| for rep_id, members in cluster_members.items(): |
| labels = df.loc[df["variant_key"].isin(members), "label"] |
| n_mem = len(members) |
| cluster_rows.append( |
| { |
| "cluster_id": rep_id, |
| "n_members": n_mem, |
| "is_singleton": n_mem == 1, |
| "external_mean_seqid": cluster_scores.get(rep_id, 0.0), |
| "split": "test" if rep_id in test_cluster_ids else "train", |
| "n_highFRET": int((labels == LABEL_HIGH).sum()), |
| "n_noFRET": int((labels == LABEL_NO).sum()), |
| "has_mixed_labels": int(labels.nunique() > 1) if n_mem > 1 else 0, |
| } |
| ) |
| cluster_summary = pd.DataFrame(cluster_rows).sort_values( |
| "external_mean_seqid", ascending=True |
| ) |
| cluster_summary.to_csv(cluster_summary_csv, index=False) |
| print(f"Saved cluster summary to: {cluster_summary_csv}") |
|
|
| print("\nSplit summary:") |
| for label in (LABEL_HIGH, LABEL_NO): |
| sub = df[df["label"] == label] |
| if len(sub) == 0: |
| continue |
| n_test = (sub["split"] == "test").sum() |
| n_train = (sub["split"] == "train").sum() |
| print(f" {label}: train={n_train}, test={n_test} ({100 * n_test / len(sub):.1f}% test)") |
|
|
| if not cross_df.empty: |
| print("\nCross-class similarity (test sequences vs train, other label):") |
| print( |
| f" mean of per-seq max seqid: " |
| f"{cross_df['max_seqid_to_other_label_train'].mean():.4f}" |
| ) |
| print( |
| f" mean of per-seq mean seqid: " |
| f"{cross_df['mean_seqid_to_other_label_train'].mean():.4f}" |
| ) |
| print( |
| f" fraction with max other-label train seqid >= {args.cluster_identity}: " |
| f"{(cross_df['max_seqid_to_other_label_train'] >= args.cluster_identity).mean():.2%}" |
| ) |
| print( |
| " (Low values / low fraction above threshold => stronger cross-class separation in test.)" |
| ) |
|
|
| print( |
| f"\nClustering: MMseqs2 easy-cluster at min-seq-id={args.cluster_identity}, " |
| f"coverage={args.cluster_coverage}. " |
| "Test = whole clusters with lowest external mean seqid." |
| ) |
| finally: |
| if not args.keep_temp and os.path.exists(tmp_dir): |
| shutil.rmtree(tmp_dir) |
| elif args.keep_temp: |
| print(f"Temporary files kept: {tmp_dir}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|