FRET-FACS / similarity_split /mmseqs_cluster_split.py
neuwirtt
Add MMseqs2 cluster split utility and clarify dataset naming.
d683a90
Raw
History Blame Contribute Delete
32.3 kB
#!/usr/bin/env python3
"""
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"
# Train/test deliverables: same column layout as the FRET selection CSV (no split column).
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
# Label composition inside multi-member clusters only
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()