File size: 9,041 Bytes
1f94fe4 c3b49d6 1f94fe4 c3b49d6 1f94fe4 c3b49d6 1f94fe4 c3b49d6 1f94fe4 c3b49d6 1f94fe4 c3b49d6 1f94fe4 c3b49d6 1f94fe4 c3b49d6 1f94fe4 c3b49d6 1f94fe4 c3b49d6 1f94fe4 c3b49d6 1f94fe4 c3b49d6 1f94fe4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 | """Draft a manifest from an uploaded file (ADR-0011 friction mitigation).
The gate requires a manifest (item 2), which is the main friction for a
non-coding uploader. This helper does the ADR's "drafts the manifest from the
file + a few prompts": it opens the file with the SAME vetted loaders the
validator uses (``scanpy.read_h5ad`` / ``pandas.read_csv`` β never
``exec``/``eval``/``pickle``), infers what it safely can (data level, feature-ID
type, sample/feature counts, candidate grouping columns), and returns a manifest
skeleton plus an explicit ``todo`` list of the fields a human must still confirm.
It does NOT weaken the gate: a drafted manifest is just a starting point that
still has to clear :func:`validate_upload` (which re-checks these same
inferences against the data) and admin registration. It only removes the
blank-page problem, so the honest workflow is:
draft = draft_manifest(path) # inspect + pre-fill
# uploader reviews draft.todo, edits draft.manifest
rec = stage_upload(path, manifest=draft.manifest, ...)
validate_upload(rec) # re-verifies the inferences
A backing UI (local Gradio panel) can wrap this to present ``manifest`` as an
editable form with ``todo`` as the required-fields checklist; the inference is
here so the UI stays a thin shell.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# detected data_type (microarray classifier) β manifest data_level vocabulary.
_DATA_LEVEL_MAP = {
"raw_counts": "raw_counts",
"log_expression": "log_expression",
"log_ratio_microarray": "log_ratio",
"unknown": None,
}
# detected feature pattern (check_feature_id_type) β manifest feature_id_type.
_FEATURE_MAP = {
"gene_symbol": "gene_symbol",
"ensembl": "ensembl_gene_id",
"entrez": "entrez_id",
"probe_id_or_unknown": None,
}
_TABULAR_SUFFIXES = (".csv", ".tsv", ".txt", ".csv.gz", ".tsv.gz", ".txt.gz")
_MAX_X = 50_000
_TODO = "TODO"
@dataclass
class DraftResult:
"""A drafted manifest plus what still needs a human."""
manifest: dict = field(default_factory=dict)
inferred: dict = field(default_factory=dict) # field β (value, evidence)
todo: list[str] = field(default_factory=list) # required fields still unfilled
notes: list[str] = field(default_factory=list)
def _kind_of(name: str) -> str | None:
low = name.lower()
if low.endswith(".h5ad"):
return "h5ad"
if low.endswith(_TABULAR_SUFFIXES):
return "tabular"
return None
def _cap_flat(X_flat):
import numpy as np
if len(X_flat) > _MAX_X:
return np.random.default_rng(0).choice(X_flat, size=_MAX_X, replace=False)
return X_flat
def _infer_data_level(X_flat) -> tuple[str | None, str]:
from src.workflows.microarray import classify_expression_data_type
info = classify_expression_data_type(X_flat)
detected = info["data_type"]
level = _DATA_LEVEL_MAP.get(detected)
ev = (
f"detected '{detected}' (integer={info['is_integer']}, "
f"min={info['value_min']}, max={info['value_max']})"
)
return level, ev
def _infer_feature_id_type(var_index: list[str]) -> tuple[str | None, str]:
from src.workflows.manifest_data_validation import check_feature_id_type
# declared arg only affects wording; detected_pattern is computed from the data.
res = check_feature_id_type(var_index, "gene_symbol")
pattern = res.get("detected_pattern", "probe_id_or_unknown")
return _FEATURE_MAP.get(pattern), f"var.index looks like '{pattern}' β {res['message']}"
def _candidate_group_columns(obs) -> list[str]:
"""obs columns that look like usable grouping factors (2β12 categories)."""
n = len(obs)
out: list[str] = []
for col in obs.columns:
try:
nu = int(obs[col].astype(str).nunique())
except Exception: # noqa: BLE001
continue
if 2 <= nu <= 12 and nu < n:
out.append(str(col))
return out
def _base_manifest(
dataset_id: str,
*,
organism: str,
modality: str,
data_level: str | None,
feature_id_type: str | None,
group_columns: list[str],
embedded_obs: bool,
title: str | None,
) -> dict:
return {
"dataset_id": dataset_id,
"title": title or f"{_TODO}: descriptive title for {dataset_id}",
"accession": _TODO,
"organism": organism,
"modality": modality,
"platform": _TODO,
"data_level": data_level or _TODO,
"feature_id_type": feature_id_type or _TODO,
"expression_source": {"type": "local"},
"metadata_source": {"type": "local", "embedded": embedded_obs},
"group_columns": group_columns,
"valid_workflows": [],
"limitations": [
"Manifest auto-drafted from the uploaded file β review every field before registering.",
],
}
def draft_manifest(
path: str | Path,
*,
dataset_id: str | None = None,
title: str | None = None,
organism: str = "human",
) -> DraftResult:
"""Inspect an upload and return a pre-filled manifest skeleton + a todo list.
Parameters mirror the few prompts a UI would ask (``dataset_id``, ``title``,
``organism``); everything else is inferred from the file or left as ``TODO``.
Raises ``ValueError`` for an unsupported file type or an unreadable file β the
caller surfaces that to the uploader.
"""
path = Path(path)
kind = _kind_of(path.name)
if kind is None:
raise ValueError(
f"Cannot draft a manifest for '{path.name}': supported types are "
".h5ad and flat matrices (.csv/.tsv/.txt, optionally .gz)."
)
dataset_id = dataset_id or _slug(path.name)
inferred: dict[str, Any] = {}
notes: list[str] = []
if kind == "h5ad":
import scanpy as sc
adata = sc.read_h5ad(path) # vetted loader β never exec
X = adata.X
if hasattr(X, "toarray"):
X = X.toarray()
X_flat = _cap_flat(X.flatten())
var_index = [str(v) for v in adata.var.index]
data_level, dl_ev = _infer_data_level(X_flat)
feat, feat_ev = _infer_feature_id_type(var_index)
group_cols = _candidate_group_columns(adata.obs)
n_samples, n_features = int(adata.n_obs), int(adata.n_vars)
modality = "sc_rnaseq" if n_samples > 2000 else "bulk_rnaseq"
notes.append(
f"modality guessed '{modality}' from {n_samples} obs β confirm "
"(single-cell vs bulk changes the analysis path)."
)
inferred["group_columns"] = (
group_cols,
f"low-cardinality obs columns among {list(adata.obs.columns)}",
)
embedded_obs = True
else:
import pandas as pd
name = path.name.lower()
base = name[:-3] if name.endswith(".gz") else name
sep = "\t" if base.endswith((".tsv", ".txt")) else ","
df = pd.read_csv(path, index_col=0, sep=sep) # vetted reader β never exec
X_flat = _cap_flat(df.to_numpy(dtype=float).flatten())
var_index = [str(c) for c in df.columns]
data_level, dl_ev = _infer_data_level(X_flat)
feat, feat_ev = _infer_feature_id_type(var_index)
group_cols = []
n_samples, n_features = int(df.shape[0]), int(df.shape[1])
modality = "bulk_rnaseq"
notes.append(
"Flat matrix has no sample metadata β group_columns left empty. "
"Re-upload as .h5ad with embedded obs to draft grouping/contrasts."
)
notes.append("Assumed orientation: samples as rows, genes as columns (index_col=0).")
embedded_obs = False
inferred["data_level"] = (data_level, dl_ev)
inferred["feature_id_type"] = (feat, feat_ev)
inferred["n_samples"] = (n_samples, "")
inferred["n_features"] = (n_features, "")
manifest = _base_manifest(
dataset_id,
organism=organism,
modality=modality,
data_level=data_level,
feature_id_type=feat,
group_columns=group_cols,
embedded_obs=embedded_obs,
title=title,
)
# Everything still needing a human: unfilled required fields + workflows.
todo = [k for k, v in manifest.items() if v == _TODO]
if not manifest["valid_workflows"]:
todo.append("valid_workflows")
if not manifest["group_columns"] and kind == "h5ad":
notes.append("No obvious grouping column found β set group_columns manually.")
return DraftResult(manifest=manifest, inferred=inferred, todo=todo, notes=notes)
def _slug(filename: str) -> str:
stem = filename.lower()
for suf in (".csv.gz", ".tsv.gz", ".txt.gz", ".h5ad", ".csv", ".tsv", ".txt"):
if stem.endswith(suf):
stem = stem[: -len(suf)]
break
keep = [c if (c.isalnum() or c == "_") else "_" for c in stem]
slug = "".join(keep).strip("_") or "uploaded_dataset"
return slug
|