| """ |
| Validates a loaded dataset against its manifest declaration. |
| |
| DESIGN |
| ------ |
| All functions accept plain numpy arrays and pandas DataFrames — no scanpy or |
| AnnData imports. The MCP tool (dataset_validate_manifest_against_data in |
| dataset_tools.py) handles h5ad loading and calls validate_manifest_against_data. |
| |
| Five check categories |
| --------------------- |
| data_level Detected expression type vs. manifest declaration. |
| feature_id_type Spot-check var.index format (gene symbols, Ensembl, etc.). |
| metadata_columns Declared columns present in obs; values within allowed set. |
| group_columns Each group column exists and has ≥2 usable groups. |
| default_contrasts Each contrast has ≥3 samples per group after subset_query. |
| |
| Return structure |
| ---------------- |
| All public functions return a dict with at minimum: |
| status "pass" | "warning" | "error" |
| message short human-readable summary |
| |
| validate_manifest_against_data returns: |
| overall_valid bool |
| n_errors int |
| n_warnings int |
| checks dict of category → result dict |
| errors list[str] — blocking issues |
| warnings list[str] — non-blocking issues |
| recommendations list[str] — suggested next steps |
| """ |
|
|
| from __future__ import annotations |
|
|
| import re |
| from typing import Any |
|
|
| import numpy as np |
| import pandas as pd |
|
|
| |
| |
| |
| |
|
|
| _COMPAT: dict[str, set[str]] = { |
| "raw_counts": {"raw_counts"}, |
| "log_expression": {"log_expression", "normalized", "tpm", "fpkm", "protein_abundance"}, |
| "log_ratio_microarray": {"log_ratio", "log_expression", "normalized"}, |
| "unknown": { |
| "raw_counts", |
| "log_expression", |
| "log_ratio", |
| "normalized", |
| "tpm", |
| "fpkm", |
| "protein_abundance", |
| }, |
| } |
|
|
| |
| _GENE_SYMBOL_RE = re.compile(r"^[A-Z][A-Z0-9\-\.]{1,19}$") |
| _ENSEMBL_HUMAN = re.compile(r"^ENSG\d{11}$") |
| _ENSEMBL_MOUSE = re.compile(r"^ENSMUSG\d{11}$") |
| _ENTREZ_RE = re.compile(r"^\d+$") |
|
|
| _MANIFEST_TO_DETECTED: dict[str, str] = { |
| "gene_symbol": "gene_symbol", |
| "ensembl_gene_id": "ensembl", |
| "entrez_id": "entrez", |
| "probe_id": "probe_id", |
| "protein_id": "protein_id", |
| } |
|
|
|
|
| |
| |
| |
|
|
|
|
| def check_data_level( |
| X_flat: np.ndarray, |
| declared_data_level: str, |
| ) -> dict[str, Any]: |
| """ |
| Compare manifest-declared data_level against detected expression type. |
| |
| Parameters |
| ---------- |
| X_flat: |
| 1-D numpy array sampled from adata.X (up to ~50 000 values is enough). |
| declared_data_level: |
| Value of manifest.data_level (e.g. 'log_expression', 'raw_counts'). |
| |
| Returns |
| ------- |
| dict with: status, declared, detected, compatible, message, details. |
| """ |
| from src.workflows.microarray import classify_expression_data_type |
|
|
| info = classify_expression_data_type(X_flat) |
| detected = info["data_type"] |
| compatible = declared_data_level in _COMPAT.get(detected, set()) |
|
|
| if detected == "unknown": |
| status = "warning" |
| msg = ( |
| f"Could not confidently classify expression data " |
| f"(min={info['value_min']}, max={info['value_max']}, " |
| f"is_integer={info['is_integer']}, has_negatives={info['has_negatives']}). " |
| f"Manifest declares '{declared_data_level}' — verify manually." |
| ) |
| elif compatible: |
| status = "pass" |
| msg = ( |
| f"Detected '{detected}' is compatible with declared data_level='{declared_data_level}'." |
| ) |
| else: |
| status = "error" |
| msg = ( |
| f"MISMATCH: manifest declares data_level='{declared_data_level}' " |
| f"but data appears to be '{detected}'. " |
| f"Check whether the correct data level is declared in the manifest, " |
| f"or whether a normalisation step was already applied." |
| ) |
|
|
| return { |
| "status": status, |
| "declared": declared_data_level, |
| "detected": detected, |
| "compatible": compatible, |
| "message": msg, |
| "details": { |
| k: info[k] |
| for k in ( |
| "is_integer", |
| "has_negatives", |
| "value_min", |
| "value_max", |
| "value_mean", |
| "value_median", |
| ) |
| }, |
| } |
|
|
|
|
| def check_feature_id_type( |
| var_index_sample: list[str], |
| declared_type: str, |
| ) -> dict[str, Any]: |
| """ |
| Spot-check var.index values to verify they match the declared feature_id_type. |
| |
| Samples up to 200 values and checks the fraction matching each pattern. |
| Returns a warning (not error) when confidence is low, since probe ID |
| formats vary too much across platforms for a definitive check. |
| |
| Parameters |
| ---------- |
| var_index_sample: |
| List of feature names from adata.var.index (sample or full list). |
| declared_type: |
| manifest.feature_id_type value. |
| """ |
| sample = var_index_sample[:200] |
| n = len(sample) |
| if n == 0: |
| return { |
| "status": "error", |
| "declared": declared_type, |
| "detected_pattern": "unknown", |
| "message": "var.index is empty — cannot check feature ID type.", |
| } |
|
|
| n_gene = sum(1 for v in sample if _GENE_SYMBOL_RE.match(str(v))) |
| n_ensh = sum(1 for v in sample if _ENSEMBL_HUMAN.match(str(v))) |
| n_ensm = sum(1 for v in sample if _ENSEMBL_MOUSE.match(str(v))) |
| n_entrez = sum(1 for v in sample if _ENTREZ_RE.match(str(v))) |
|
|
| fracs = { |
| "gene_symbol": n_gene / n, |
| "ensembl": (n_ensh + n_ensm) / n, |
| "entrez": n_entrez / n, |
| } |
|
|
| |
| ensembl_frac = (n_ensh + n_ensm) / n |
| if ensembl_frac >= 0.5: |
| detected_pattern = "ensembl" |
| best_frac = ensembl_frac |
| best_pattern = "ensembl" |
| else: |
| best_pattern, best_frac = max(fracs.items(), key=lambda x: x[1]) |
| detected_pattern = best_pattern if best_frac >= 0.5 else "probe_id_or_unknown" |
|
|
| expected_pattern = _MANIFEST_TO_DETECTED.get(declared_type, declared_type) |
| |
| |
| if declared_type in ("probe_id", "protein_id"): |
| if best_frac > 0.7 and best_pattern in ("gene_symbol", "ensembl", "entrez"): |
| status = "warning" |
| msg = ( |
| f"Manifest declares feature_id_type='{declared_type}' but " |
| f"{best_frac:.0%} of var.index values look like '{best_pattern}'. " |
| f"If probes were already collapsed to gene symbols, " |
| f"update feature_id_type to 'gene_symbol'." |
| ) |
| else: |
| status = "pass" |
| msg = ( |
| f"feature_id_type='{declared_type}' — pattern not automatically " |
| f"verifiable (platform-specific). Sample: {sample[:5]}" |
| ) |
| elif detected_pattern == expected_pattern: |
| status = "pass" |
| msg = ( |
| f"var.index looks like '{detected_pattern}' ({best_frac:.0%} match), " |
| f"consistent with declared feature_id_type='{declared_type}'." |
| ) |
| elif detected_pattern == "probe_id_or_unknown": |
| status = "warning" |
| msg = ( |
| f"Could not confidently classify var.index format " |
| f"(best match '{best_pattern}' at only {best_frac:.0%}). " |
| f"Manifest declares '{declared_type}'. Sample: {sample[:5]}" |
| ) |
| else: |
| status = "warning" |
| msg = ( |
| f"var.index looks like '{detected_pattern}' ({best_frac:.0%} match) " |
| f"but manifest declares feature_id_type='{declared_type}'. " |
| f"Sample: {sample[:5]}" |
| ) |
|
|
| return { |
| "status": status, |
| "declared": declared_type, |
| "detected_pattern": detected_pattern, |
| "pattern_fractions": {k: round(v, 3) for k, v in fracs.items()}, |
| "n_sampled": n, |
| "sample_features": sample[:10], |
| "message": msg, |
| } |
|
|
|
|
| def check_metadata_columns( |
| obs_df: pd.DataFrame, |
| manifest: Any, |
| ) -> dict[str, Any]: |
| """ |
| Verify declared metadata_columns exist in obs and values match allowed set. |
| |
| Parameters |
| ---------- |
| obs_df: |
| adata.obs as a pandas DataFrame. |
| manifest: |
| DatasetManifest instance. |
| """ |
| if not manifest.metadata_columns: |
| return { |
| "status": "pass", |
| "message": "No metadata_columns declared in manifest — skipped.", |
| "n_checked": 0, |
| "columns": {}, |
| } |
|
|
| columns: dict[str, Any] = {} |
| errors: list[str] = [] |
| warnings: list[str] = [] |
|
|
| for col_name, col_def in manifest.metadata_columns.items(): |
| if hasattr(col_def, "role"): |
| decoded_col = col_def.decoded_column or col_name |
| source_col = col_def.source_column |
| allowed = set(col_def.allowed_values) |
| missing_vals = set(col_def.missing_values) | {"", "nan", "None"} |
| else: |
| decoded_col = col_def.get("decoded_column") or col_name |
| source_col = col_def.get("source_column") |
| allowed = set(col_def.get("allowed_values") or []) |
| missing_vals = set(col_def.get("missing_values") or []) | {"", "nan", "None"} |
|
|
| check_col = decoded_col |
| result: dict[str, Any] = {"check_column": check_col, "source_column": source_col} |
|
|
| if check_col not in obs_df.columns: |
| if source_col and source_col in obs_df.columns: |
| result["status"] = "warning" |
| result["message"] = ( |
| f"Source column '{source_col}' present but decoded column " |
| f"'{check_col}' missing — numeric decoder may not have run." |
| ) |
| warnings.append(result["message"]) |
| else: |
| result["status"] = "error" |
| result["message"] = f"Column '{check_col}' not found in obs." |
| errors.append(result["message"]) |
| columns[col_name] = result |
| continue |
|
|
| actual_vals = obs_df[check_col].astype(str) |
| non_missing = actual_vals[~actual_vals.isin(missing_vals)] |
| value_counts = actual_vals.value_counts().to_dict() |
| result["value_counts"] = {str(k): int(v) for k, v in value_counts.items()} |
| result["n_missing"] = int(actual_vals.isin(missing_vals).sum()) |
| result["n_annotated"] = int(len(non_missing)) |
|
|
| if allowed: |
| unexpected = sorted(set(non_missing.unique()) - allowed) |
| result["unexpected_values"] = unexpected |
| if unexpected: |
| result["status"] = "warning" |
| result["message"] = ( |
| f"Unexpected values in '{check_col}': {unexpected}. " |
| f"Declared allowed: {sorted(allowed)}" |
| ) |
| warnings.append(result["message"]) |
| else: |
| result["status"] = "pass" |
| result["message"] = ( |
| f"All non-missing values in '{check_col}' match declared " |
| f"allowed_values ({len(non_missing)} annotated, " |
| f"{result['n_missing']} missing)." |
| ) |
| else: |
| result["unexpected_values"] = [] |
| result["status"] = "pass" |
| result["message"] = ( |
| f"Column '{check_col}' present ({len(actual_vals)} values; " |
| f"no allowed_values constraint declared)." |
| ) |
|
|
| columns[col_name] = result |
|
|
| overall = "error" if errors else ("warning" if warnings else "pass") |
| return { |
| "status": overall, |
| "n_checked": len(columns), |
| "columns": columns, |
| "message": ( |
| f"{len(errors)} error(s), {len(warnings)} warning(s) " |
| f"across {len(columns)} declared metadata columns." |
| ), |
| } |
|
|
|
|
| def check_group_columns( |
| obs_df: pd.DataFrame, |
| manifest: Any, |
| min_group_size: int = 3, |
| ) -> dict[str, Any]: |
| """ |
| Verify each group_column exists in obs and has ≥2 groups with enough samples. |
| |
| Parameters |
| ---------- |
| obs_df: |
| adata.obs as a pandas DataFrame. |
| manifest: |
| DatasetManifest instance. |
| min_group_size: |
| Minimum samples per group (default 3). |
| """ |
| columns: dict[str, Any] = {} |
| errors: list[str] = [] |
| warnings: list[str] = [] |
|
|
| for col in manifest.group_columns: |
| if col not in obs_df.columns: |
| columns[col] = { |
| "status": "error", |
| "present": False, |
| "message": f"group_column '{col}' not found in obs.", |
| } |
| errors.append(f"group_column '{col}' missing from obs") |
| continue |
|
|
| vc = obs_df[col].astype(str).value_counts() |
| qualifying = {str(k): int(v) for k, v in vc.items() if v >= min_group_size} |
| small = {str(k): int(v) for k, v in vc.items() if v < min_group_size} |
| n_usable = len(qualifying) |
|
|
| if n_usable < 2: |
| status = "warning" |
| msg = ( |
| f"Column '{col}' has fewer than 2 groups with ≥{min_group_size} " |
| f"samples (qualifying: {list(qualifying.keys())})." |
| ) |
| warnings.append(msg) |
| else: |
| status = "pass" |
| msg = f"Column '{col}' has {n_usable} usable groups (≥{min_group_size} samples each)." |
|
|
| columns[col] = { |
| "status": status, |
| "present": True, |
| "n_unique": int(vc.nunique()), |
| "qualifying_groups": qualifying, |
| "small_groups": small, |
| "message": msg, |
| } |
|
|
| overall = "error" if errors else ("warning" if warnings else "pass") |
| return { |
| "status": overall, |
| "n_checked": len(manifest.group_columns), |
| "columns": columns, |
| "message": ( |
| f"{len(errors)} error(s), {len(warnings)} warning(s) " |
| f"across {len(manifest.group_columns)} group_column(s)." |
| ), |
| } |
|
|
|
|
| def check_default_contrasts( |
| obs_df: pd.DataFrame, |
| manifest: Any, |
| min_group_size: int = 3, |
| ) -> dict[str, Any]: |
| """ |
| Verify each default_contrast has enough samples in both groups. |
| |
| Applies subset_query if declared, then counts test and control samples. |
| |
| Parameters |
| ---------- |
| obs_df: |
| adata.obs as a pandas DataFrame. |
| manifest: |
| DatasetManifest instance. |
| min_group_size: |
| Minimum samples per group for a contrast to be feasible (default 3). |
| """ |
| if not manifest.default_contrasts: |
| return { |
| "status": "pass", |
| "message": "No default_contrasts declared — skipped.", |
| "contrasts": [], |
| } |
|
|
| results: list[dict] = [] |
| errors: list[str] = [] |
| warnings: list[str] = [] |
|
|
| for i, contrast in enumerate(manifest.default_contrasts): |
| factor = contrast.get("design_factor", "") |
| test_grp = contrast.get("test_group", "") |
| ctrl_grp = contrast.get("control_group", "") |
| subset_q = contrast.get("subset_query") |
| method = contrast.get("method", "ttest") |
| label = f"{test_grp} vs {ctrl_grp} (contrast {i})" |
|
|
| entry: dict[str, Any] = { |
| "design_factor": factor, |
| "test_group": test_grp, |
| "control_group": ctrl_grp, |
| "subset_query": subset_q, |
| "method": method, |
| } |
|
|
| |
| working_df = obs_df |
| if subset_q: |
| try: |
| working_df = obs_df.query(subset_q) |
| entry["n_after_subset"] = len(working_df) |
| except Exception as exc: |
| entry["status"] = "error" |
| entry["message"] = f"subset_query failed: {exc}" |
| errors.append(entry["message"]) |
| results.append(entry) |
| continue |
|
|
| if factor not in working_df.columns: |
| entry["status"] = "error" |
| entry["message"] = ( |
| f"design_factor '{factor}' not found in obs (after applying subset_query)." |
| ) |
| errors.append(entry["message"]) |
| results.append(entry) |
| continue |
|
|
| col = working_df[factor].astype(str) |
| n_test = int((col == test_grp).sum()) |
| n_ctrl = int((col == ctrl_grp).sum()) |
| entry["n_test"] = n_test |
| entry["n_control"] = n_ctrl |
|
|
| if n_test == 0 or n_ctrl == 0: |
| entry["status"] = "error" |
| entry["message"] = ( |
| f"{label}: one or both groups have 0 samples " |
| f"(test='{test_grp}': {n_test}, control='{ctrl_grp}': {n_ctrl}). " |
| f"Check group label spelling and subset_query." |
| ) |
| errors.append(entry["message"]) |
| elif n_test < min_group_size or n_ctrl < min_group_size: |
| entry["status"] = "warning" |
| entry["message"] = ( |
| f"{label}: groups are small " |
| f"(test={n_test}, control={n_ctrl}, min={min_group_size}). " |
| f"Results may be underpowered." |
| ) |
| warnings.append(entry["message"]) |
| else: |
| entry["status"] = "pass" |
| entry["message"] = f"{label}: feasible — test={n_test}, control={n_ctrl}." |
|
|
| results.append(entry) |
|
|
| overall = "error" if errors else ("warning" if warnings else "pass") |
| return { |
| "status": overall, |
| "n_checked": len(manifest.default_contrasts), |
| "contrasts": results, |
| "message": ( |
| f"{len(errors)} error(s), {len(warnings)} warning(s) " |
| f"across {len(manifest.default_contrasts)} contrast(s)." |
| ), |
| } |
|
|
|
|
| |
| |
| |
|
|
|
|
| def validate_manifest_against_data( |
| X_flat: np.ndarray, |
| var_index: list[str], |
| obs_df: pd.DataFrame, |
| manifest: Any, |
| ) -> dict[str, Any]: |
| """ |
| Run all five checks and return a consolidated validation report. |
| |
| Parameters |
| ---------- |
| X_flat: |
| 1-D numpy array of expression values (sample up to ~50 000 values). |
| var_index: |
| Feature names from adata.var.index. |
| obs_df: |
| adata.obs as a pandas DataFrame. |
| manifest: |
| DatasetManifest instance. |
| |
| Returns |
| ------- |
| dict with: overall_valid, n_errors, n_warnings, checks, errors, |
| warnings, recommendations. |
| """ |
| checks = { |
| "data_level": check_data_level(X_flat, manifest.data_level), |
| "feature_id_type": check_feature_id_type(var_index, manifest.feature_id_type), |
| "metadata_columns": check_metadata_columns(obs_df, manifest), |
| "group_columns": check_group_columns(obs_df, manifest), |
| "default_contrasts": check_default_contrasts(obs_df, manifest), |
| } |
|
|
| all_errors: list[str] = [] |
| all_warnings: list[str] = [] |
|
|
| for name, result in checks.items(): |
| status = result.get("status", "pass") |
| if status == "error": |
| all_errors.append(f"[{name}] {result.get('message', '')}") |
| elif status == "warning": |
| all_warnings.append(f"[{name}] {result.get('message', '')}") |
|
|
| overall_valid = len(all_errors) == 0 |
|
|
| recommendations: list[str] = [] |
| if overall_valid and not all_warnings: |
| recommendations.append( |
| "Manifest is consistent with loaded data. " |
| "Proceed with dataset_plan_analysis to start analysis." |
| ) |
| if all_errors: |
| recommendations.append( |
| "Fix errors before running analysis — they indicate manifest " |
| "declarations that contradict the actual data." |
| ) |
| if any("[feature_id_type]" in w for w in all_warnings): |
| recommendations.append( |
| "Verify feature_id_type by inspecting adata.var.index directly " |
| "and updating the manifest if the format has changed." |
| ) |
| if any("[metadata_columns]" in w for w in all_warnings): |
| recommendations.append( |
| "Review unexpected metadata values — they may indicate new " |
| "categories in the data or a stale manifest." |
| ) |
| if any("decoder" in w for w in all_warnings): |
| recommendations.append( |
| "Re-run decoupler_load_geo_series_matrix with decode_numeric=True " |
| "to apply numeric-to-label mapping." |
| ) |
|
|
| return { |
| "dataset_id": manifest.dataset_id, |
| "n_samples": len(obs_df), |
| "n_features": len(var_index), |
| "overall_valid": overall_valid, |
| "n_errors": len(all_errors), |
| "n_warnings": len(all_warnings), |
| "checks": checks, |
| "errors": all_errors, |
| "warnings": all_warnings, |
| "recommendations": recommendations, |
| } |
|
|