Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Deep generative models for single-cell omics: probabilistic batch correction (scVI), semi-supervised annotation (scANVI), CITE-seq RNA+protein (totalVI), transfer learning (scARCHES), and DE with uncertainty. Unified setup→train→extract API on AnnData. Use harmony-batch-correction for fast linear correction without deep learning; muon for multi-modal MuData workflows.
.claude/skills/jaechang-hits-scvi-tools-single-cell/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-04 | ✗→✓ | ▲ Improved | 199% | 0% |
| case-21 | ✗→✓ | ▲ Improved | 509% | 0% |
| case-13 | ✓→✗ | ▼ Worse | 441% | 0% |
| case-01 | ✓→✓ | = Same ✓ | 276% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 267% | 0% |
scvi-tools is a probabilistic modeling framework for single-cell genomics built on PyTorch. It implements variational autoencoders (VAEs) that learn low-dimensional latent representations of cells while explicitly modeling batch effects, count noise distributions, and multi-modal data. All models share a unified API: setup_anndata() to register data, instantiate the model, train(), then extract latent representations, normalized expression, or differential expression results. Models operate on raw count data in AnnData format and return statistically grounded outputs with uncertainty estimates.
scvi-tools>=1.1, scanpy, anndata.h5ad) with raw counts — not log-normalized. Store counts in adata.layers["counts"] if adata.X has been normalized.bashpip install scvi-tools scanpy # GPU acceleration (recommended for >50k cells) pip install "scvi-tools[cuda12]" # or scvi-tools[cuda11]
Minimal scVI batch integration on a built-in example dataset:
pythonimport scvi import scanpy as sc # Load example data with batch labels adata = scvi.data.heart_cell_atlas_subsampled() # Preprocessing: filter genes, select HVGs (subset to save training time) sc.pp.filter_genes(adata, min_counts=3) adata.layers["counts"] = adata.X.copy() # preserve raw counts sc.pp.normalize_total(adata, target_sum=1e4) sc.pp.log1p(adata) sc.pp.highly_variable_genes(adata, n_top_genes=2000, batch_key="cell_source", subset=True) # Register data, train, extract scvi.model.SCVI.setup_anndata(adata, layer="counts", batch_key="cell_source") model = scvi.model.SCVI(adata, n_latent=30) model.train(max_epochs=200, early_stopping=True) adata.obsm["X_scVI"] = model.get_latent_representation() sc.pp.neighbors(adata, use_rep="X_scVI") sc.tl.umap(adata) sc.tl.leiden(adata, resolution=0.5) sc.pl.umap(adata, color=["cell_source", "leiden"]) print(f"Latent shape: {adata.obsm['X_scVI'].shape}") # Latent shape: (14000, 30)
All models share the same registration pattern. Call setup_anndata() on the model class before instantiation to tell scvi-tools where to find counts, batch labels, and covariates.
pythonimport scvi # Minimal: counts in a layer, batch column in obs scvi.model.SCVI.setup_anndata( adata, layer="counts", # Key in adata.layers with raw counts; None = adata.X batch_key="batch", # Column in adata.obs for technical batch ) # Extended: additional categorical and continuous covariates scvi.model.SCVI.setup_anndata( adata, layer="counts", batch_key="batch", categorical_covariate_keys=["donor", "protocol"], # Discrete biological/technical vars continuous_covariate_keys=["percent_mito", "log_n_counts"], # Continuous covariates ) # Inspect registered summary print(adata.uns["_scvi"]["summary_stats"]) # {'n_vars': 2000, 'n_cells': 45000, 'n_batch': 6, 'n_extra_categorical_covs': 2, ...}
The core unsupervised model. Learns a batch-corrected latent space and a denoised expression layer. Starting point for any multi-batch scRNA-seq analysis.
pythonimport scvi model = scvi.model.SCVI( adata, n_latent=30, # Latent space dimensions; 10–50 typical n_layers=2, # Hidden layers in encoder/decoder; 1–3 n_hidden=128, # Neurons per hidden layer; 64–256 gene_likelihood="zinb", # "zinb" (zero-inflated NB), "nb", or "poisson" dispersion="gene", # "gene" or "gene-batch" for batch-specific dispersion ) model.train(max_epochs=200, early_stopping=True) # Extract results latent = model.get_latent_representation() # ndarray (n_cells, n_latent) normalized = model.get_normalized_expression( library_size=1e4, n_samples=25, return_mean=True ) print(f"Latent: {latent.shape}") print(f"Denoised expression: {normalized.shape}") # Latent: (45000, 30) # Denoised expression: (45000, 2000) adata.obsm["X_scVI"] = latent adata.layers["scvi_normalized"] = normalized
Extends scVI with label supervision for cell type transfer learning. Accepts partially labeled data (unannotated cells labeled "Unknown") and predicts cell types for unlabeled cells.
pythonimport scvi # Register with cell type labels; mark unannotated cells as "Unknown" scvi.model.SCANVI.setup_anndata( adata, layer="counts", batch_key="batch", labels_key="cell_type", # Column in adata.obs with known labels unlabeled_category="Unknown", # Sentinel value for unannotated cells ) # Recommended: initialize from a pretrained scVI model scvi_model = scvi.model.SCVI(adata, n_latent=30) scvi_model.train(max_epochs=200, early_stopping=True) model = scvi.model.SCANVI.from_scvi_model(scvi_model, unlabeled_category="Unknown") model.train(max_epochs=20) # Short fine-tuning on top of scVI # Predict cell types labels_pred = model.predict() # Hard labels (str per cell) probs = model.predict(soft=True) # DataFrame: probability per cell type confidence = probs.max(axis=1) adata.obs["scANVI_pred"] = labels_pred adata.obs["scANVI_confidence"] = confidence print(f"Median confidence: {confidence.median():.3f}") print(f"High-confidence cells (>0.7): {(confidence > 0.7).sum()}")
Joint probabilistic model for CITE-seq data. Denoises both RNA and protein counts and estimates protein foreground probability (signal vs background).
pythonimport scvi # Protein counts must be in adata.obsm (not adata.X or layers) # adata.obsm["protein_expression"] shape: (n_cells, n_proteins) scvi.model.TOTALVI.setup_anndata( adata, layer="counts", batch_key="batch", protein_expression_obsm_key="protein_expression", ) model = scvi.model.TOTALVI(adata, latent_distribution="normal") model.train(max_epochs=200, early_stopping=True) # Extract joint latent space (RNA + protein) latent = model.get_latent_representation() # Denoised RNA and protein separately rna_norm, protein_norm = model.get_normalized_expression( n_samples=25, return_mean=True ) # Protein foreground probability (probability that signal > background) foreground_prob = model.get_protein_foreground_probability(n_samples=25, return_mean=True) print(f"RNA normalized: {rna_norm.shape}") print(f"Protein normalized: {protein_norm.shape}") print(f"Foreground prob: {foreground_prob.shape}") # (n_cells, n_proteins) adata.obsm["X_totalVI"] = latent
Probabilistic DE using the generative model rather than raw counts. Supports composite hypotheses testing whether effect size exceeds a biologically meaningful threshold (delta).
python# DE between two cell type groups de_df = model.differential_expression( groupby="cell_type", group1="CD4 T", # Numerator group group2="CD8 T", # Denominator group; None = rest of cells mode="change", # "change": composite |LFC| > delta; "vanilla": any LFC delta=0.25, # Minimum |LFC| to be considered DE fdr_target=0.05, # FDR level for calling significance all_stats=True, # Include mean expression per group ) # Filter significant DE genes sig = de_df[de_df["is_de_fdr_0.05"] & (de_df["lfc_mean"].abs() > 0.5)] sig_sorted = sig.sort_values("lfc_mean", ascending=False) print(f"Total DE genes (FDR<5%, |LFC|>0.5): {len(sig)}") print(sig_sorted[["lfc_mean", "bayes_factor", "proba_de"]].head(10))
python# One-vs-rest DE across all clusters (generates a dict of DataFrames) de_all = {} for ct in adata.obs["cell_type"].unique(): de_all[ct] = model.differential_expression( idx1=[adata.obs["cell_type"] == ct], # Boolean index mode="change", delta=0.25, ) sig_n = de_all[ct]["is_de_fdr_0.05"].sum() print(f" {ct}: {sig_n} DE genes vs rest")
Adapts a pretrained reference model to a new query dataset without re-training from scratch. Preserves the reference embedding structure and maps query cells into the same latent space.
pythonimport scvi # --- Reference training (done once, save the model) --- scvi.model.SCVI.setup_anndata(ref_adata, layer="counts", batch_key="batch") ref_model = scvi.model.SCVI(ref_adata, n_latent=30) ref_model.train(max_epochs=200, early_stopping=True) ref_model.save("./reference_model/", overwrite=True) # Reference annotation (use scANVI for label transfer) scvi.model.SCANVI.setup_anndata( ref_adata, layer="counts", batch_key="batch", labels_key="cell_type", unlabeled_category="Unknown", ) ref_scanvi = scvi.model.SCANVI.from_scvi_model(ref_model, unlabeled_category="Unknown") ref_scanvi.train(max_epochs=20) ref_scanvi.save("./reference_scanvi/", overwrite=True) # --- Query mapping (new dataset, minimal training) --- ref_scanvi = scvi.model.SCANVI.load("./reference_scanvi/", adata=ref_adata) # Prepare query: must share gene set with reference query_adata.obs["cell_type"] = "Unknown" # All query cells are unlabeled query_model = scvi.model.SCANVI.load_query_data(query_adata, ref_scanvi) query_model.train( max_epochs=100, plan_kwargs={"weight_decay": 0.0}, # Critical: prevents catastrophic forgetting ) # Map query into reference latent space query_latent = query_model.get_latent_representation(query_adata) query_labels = query_model.predict(query_adata) print(f"Query latent: {query_latent.shape}") print(f"Query label predictions: {query_labels[:5]}")
After extracting the latent representation, use scanpy for UMAP, clustering, and marker genes — with the batch-corrected embedding as input.
pythonimport scanpy as sc # UMAP and clustering on scVI latent representation adata.obsm["X_scVI"] = model.get_latent_representation() sc.pp.neighbors( adata, use_rep="X_scVI", # Use scVI latent (not PCA) n_neighbors=30, n_pcs=None, # n_pcs ignored when use_rep is set ) sc.tl.umap(adata) sc.tl.leiden(adata, resolution=0.5) # Marker genes using raw counts or normalized expression # Option A: scanpy Wilcoxon on log-normalized expression sc.tl.rank_genes_groups(adata, groupby="leiden", method="wilcoxon", n_genes=25) # Option B: scVI probabilistic DE per cluster (more accurate) markers = {} for cluster in adata.obs["leiden"].unique(): de = model.differential_expression( idx1=[adata.obs["leiden"] == cluster], mode="change", delta=0.25, ) markers[cluster] = de[de["is_de_fdr_0.05"]].sort_values("lfc_mean", ascending=False) # Visualization sc.pl.umap(adata, color=["leiden", "batch", "cell_type"], ncols=3) sc.pl.rank_genes_groups_dotplot(adata, n_genes=5, groupby="leiden")
All scvi-tools models follow the same 4-step workflow:
1. ModelClass.setup_anndata(adata, ...) → Register layers, batch keys, covariates
2. model = ModelClass(adata, ...) → Set architecture hyperparameters
3. model.train(max_epochs=..., ...) → Fit model (GPU auto-detected)
4. model.get_*() → Extract results: latent, normalized, DE| Data | Model | Core Feature | When to Use | |------|-------|--------------|-------------| | scRNA-seq | scVI | Batch correction, denoising | Default for any multi-batch scRNA-seq | | scRNA-seq (partial labels) | scANVI | Cell type transfer | Have reference labels; want to annotate query | | CITE-seq (RNA+protein) | totalVI | Joint RNA+protein | 10x CITE-seq, REAP-seq | | Reference → query | scARCHES | Transfer learning | Map new data to existing atlas | | Spatial | DestVI | Spot deconvolution | 10x Visium, Slide-seq | | scRNA-seq QC | Solo | Doublet detection | Pre-analysis QC step |
scvi-tools models learn count distributions directly from raw data. Log-normalized input produces incorrect results. Always preserve raw counts before normalization:
python# Correct workflow: save counts, then normalize for scanpy steps adata.layers["counts"] = adata.X.copy() # Save raw before any transformation sc.pp.normalize_total(adata, target_sum=1e4) sc.pp.log1p(adata) # Then: scvi.model.SCVI.setup_anndata(adata, layer="counts", ...)
Goal: Integrate multiple scRNA-seq datasets, remove batch effects, identify cell clusters, and find marker genes.
pythonimport scvi import scanpy as sc # Load and concatenate datasets adata1 = sc.read_h5ad("dataset1.h5ad") adata2 = sc.read_h5ad("dataset2.h5ad") adata3 = sc.read_h5ad("dataset3.h5ad") adata = sc.concat( [adata1, adata2, adata3], label="batch", keys=["dataset1", "dataset2", "dataset3"], ) # Preprocessing: preserve raw counts, select HVGs per batch adata.layers["counts"] = adata.X.copy() sc.pp.normalize_total(adata, target_sum=1e4) sc.pp.log1p(adata) sc.pp.highly_variable_genes( adata, n_top_genes=4000, batch_key="batch", # Select HVGs consistently across batches subset=True, ) print(f"HVGs selected: {adata.n_vars}") # scVI integration scvi.model.SCVI.setup_anndata(adata, layer="counts", batch_key="batch") model = scvi.model.SCVI(adata, n_latent=30, n_layers=2) model.train(max_epochs=300, early_stopping=True, early_stopping_patience=15) print(f"Training history: {model.history['elbo_train'].tail()}") # Batch-corrected analysis adata.obsm["X_scVI"] = model.get_latent_representation() adata.layers["scvi_norm"] = model.get_normalized_expression(n_samples=25, return_mean=True) sc.pp.neighbors(adata, use_rep="X_scVI", n_neighbors=30) sc.tl.umap(adata) sc.tl.leiden(adata, resolution=0.5) # Verify batch mixing (batches should overlap on UMAP) sc.pl.umap(adata, color=["batch", "leiden"], save="_batch_integration.png") model.save("./scvi_model/", overwrite=True) print(f"Clusters: {adata.obs['leiden'].nunique()}")
Goal: Model CITE-seq RNA + protein data jointly, obtain denoised protein estimates, and generate a joint embedding for annotation.
pythonimport scvi import scanpy as sc import pandas as pd # Load CITE-seq AnnData (RNA in X, protein counts in obsm) adata = sc.read_h5ad("citeseq_data.h5ad") # Expected structure: # adata.X or adata.layers["counts"] : RNA raw counts (n_cells, n_genes) # adata.obsm["protein_expression"] : Protein raw counts (n_cells, n_proteins) # Preprocessing adata.layers["counts"] = adata.X.copy() sc.pp.normalize_total(adata, target_sum=1e4) sc.pp.log1p(adata) sc.pp.highly_variable_genes(adata, n_top_genes=4000, batch_key="batch", subset=True) # totalVI setup and training scvi.model.TOTALVI.setup_anndata( adata, layer="counts", batch_key="batch", protein_expression_obsm_key="protein_expression", ) model = scvi.model.TOTALVI(adata, latent_distribution="normal") model.train(max_epochs=200, early_stopping=True) # Extract joint embedding and denoised values adata.obsm["X_totalVI"] = model.get_latent_representation() rna_norm, protein_norm = model.get_normalized_expression(n_samples=25, return_mean=True) foreground = model.get_protein_foreground_probability(n_samples=25, return_mean=True) adata.layers["rna_denoised"] = rna_norm protein_df = pd.DataFrame( protein_norm, index=adata.obs_names, columns=adata.uns["protein_names"], ) protein_df.to_csv("denoised_protein_expression.csv") print(f"Protein foreground: min={foreground.min():.3f}, max={foreground.max():.3f}") # Joint clustering on RNA + protein latent sc.pp.neighbors(adata, use_rep="X_totalVI", n_neighbors=30) sc.tl.umap(adata) sc.tl.leiden(adata, resolution=0.8) # Protein-guided annotation (use denoised protein for clean signal) protein_markers = {"B cell": "CD19", "T cell": "CD3E", "Monocyte": "CD14"} for cell_type, marker in protein_markers.items(): if marker in protein_df.columns: adata.obs[f"denoised_{marker}"] = protein_df[marker].values sc.pl.umap(adata, color=["leiden"] + [f"denoised_{m}" for m in protein_markers.values()])
| Parameter | Model / Function | Default | Range / Options | Effect | |-----------|-----------------|---------|-----------------|--------| | n_latent | All models | 10 | 10–50 | Latent space dimensionality; 20–30 typical for most datasets | | n_layers | All models | 1 | 1–3 | Depth of encoder/decoder networks | | n_hidden | All models | 128 | 64–256 | Width of each hidden layer | | gene_likelihood | scVI, scANVI | "zinb" | "zinb", "nb", "poisson" | Count noise distribution; "nb" faster if sparsity is low | | max_epochs | train() | 400 | 50–1000 | Training iterations (ELBO steps) | | early_stopping | train() | False | True/False | Halt when validation ELBO plateaus | | early_stopping_patience | train() | 45 | 5–100 | Epochs to wait before stopping | | batch_size | train() | 128 | 64–512 | Mini-batch size; reduce if OOM | | lr | train() | 1e-3 | 1e-4–1e-2 | Initial learning rate | | delta | differential_expression() | 0.25 | 0.1–1.0 | Min |LFC| threshold for composite DE | | n_samples | get_normalized_expression() | 1 | 1–100 | Posterior samples; ≥25 for stable estimates |
adata.layers["counts"] = adata.X.copy() immediately after loading data, before any normalize_total or log1p call. Passing log-normalized data silently produces incorrect latent spaces.sc.pp.highly_variable_genes(n_top_genes=2000–4000, batch_key="batch") to reduce training time and model noise. Including all genes rarely improves results and significantly slows training.batch_key or categorical_covariate_keys. Unregistered batch effects contaminate the latent space and downstream DE.from_scvi_model() after training scVI is faster, more stable, and produces better embeddings than training scANVI from scratch. Always train scVI first when using scANVI.model.save("./model_dir/") persists the full model. Reloading with SCVI.load() is seconds vs. minutes of retraining. Models are typically 10–50 MB.n_latent=20–30 as starting point: Values below 10 lose resolution; above 50 tend to overfit without added biological insight. Increase to 50 only for very heterogeneous atlases (>500k cells, many cell types).predict(soft=True).max(axis=1) < 0.7 are ambiguous; treat them as "Unknown" rather than accepting the argmax label.When to use: Remove doublets before integration or DE to avoid spurious clusters.
pythonimport scvi scvi.model.SCVI.setup_anndata(adata, layer="counts") vae = scvi.model.SCVI(adata, n_latent=20) vae.train(max_epochs=100) solo = scvi.external.SOLO.from_scvi_model(vae) solo.train(max_epochs=200) doublet_preds = solo.predict() # DataFrame: {"singlet": prob, "doublet": prob} adata.obs["doublet_score"] = doublet_preds["doublet"].values adata.obs["is_doublet"] = doublet_preds["prediction"].values n_doublets = (adata.obs["is_doublet"] == "doublet").sum() print(f"Detected {n_doublets} doublets ({n_doublets / adata.n_obs:.1%})") adata = adata[adata.obs["is_doublet"] == "singlet"].copy() print(f"Cells after doublet removal: {adata.n_obs}")
When to use: Estimate cell type proportions in spatial transcriptomics spots using a matched scRNA-seq reference.
pythonimport scvi # Step 1: Train CondSCVI on reference single-cell data scvi.model.CondSCVI.setup_anndata(sc_adata, layer="counts", labels_key="cell_type") sc_model = scvi.model.CondSCVI(sc_adata, weight_obs=False) sc_model.train(max_epochs=200) # Step 2: Deconvolve spatial spots scvi.model.DestVI.setup_anndata(st_adata, layer="counts") st_model = scvi.model.DestVI.from_rna_model(st_adata, sc_model) st_model.train(max_epochs=2500) proportions = st_model.get_proportions() # DataFrame (n_spots, n_cell_types) st_adata.obsm["cell_type_proportions"] = proportions.values print(f"Proportions per spot: {proportions.shape}") print(proportions.head())
When to use: Obtain smooth, denoised expression values for visualization or downstream machine learning when raw sparse counts are too noisy.
pythonimport scvi scvi.model.SCVI.setup_anndata(adata, layer="counts", batch_key="batch") model = scvi.model.SCVI(adata, n_latent=30) model.train(max_epochs=200, early_stopping=True) # n_samples >= 25 gives stable posterior mean; increase to 100 for publication denoised = model.get_normalized_expression( n_samples=50, library_size="latent", # "latent" uses learned library size; int for fixed normalization return_mean=True, ) adata.layers["denoised"] = denoised print(f"Denoised layer added: {denoised.shape}") # Use adata.layers["denoised"] for heatmaps, pseudotime, or ML features
| Problem | Cause | Solution | |---------|-------|----------| | ValueError: adata must contain raw counts | Log-normalized data passed instead of raw counts | Save raw before normalizing: adata.layers["counts"] = adata.X.copy() then setup_anndata(layer="counts") | | Training loss oscillates without decreasing | Learning rate too high or very heterogeneous data | Try lr=1e-4; check that adata.X and the registered layer are not sparse with negatives | | CUDA out of memory | Dataset too large for GPU VRAM | Reduce batch_size=64, n_hidden=64; subset to fewer HVGs; use CPU for small datasets | | Poor batch integration (batches separate on UMAP) | Batch key not registered, or too few epochs | Verify batch_key column exists in adata.obs; increase max_epochs; add early_stopping=True | | scANVI predicts same label for all cells | Too few labeled cells per type or learning rate issue | Need ≥50 labeled cells per type; use from_scvi_model() workflow; check unlabeled_category matches exactly | | load() fails with KeyError or shape mismatch | AnnData var_names differ from training data | Ensure query adata.var_names exactly matches training data; do not subset genes after training | | Slow training on CPU for large datasets | Large dataset without GPU acceleration | Install scvi-tools[cuda12]; pass accelerator="gpu" to train(); or subsample to 50k cells for prototyping | | scvi.model.SCANVI.load_query_data shape error | Query and reference have different gene sets | Align genes: query_adata = query_adata[:, ref_adata.var_names] before calling load_query_data |
sc.pp.neighbors(use_rep="X_scVI")| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | pass→pass | 14,240 | 6,245 | -56% | 1 | 1 | 0% | 2,389 | 8,981 | +276% | 0 | 0 | — |
case-02 | pass→pass | 13,081 | 5,125 | -61% | 1 | 1 | 0% | 2,406 | 8,829 | +267% | 0 | 0 | — |
case-03 | pass→pass | 13,785 | 4,982 | -64% | 1 | 1 | 0% | 2,281 | 8,818 | +287% | 0 | 0 | — |
case-04 | fail→pass | 17,982 | 7,122 | -60% | 1 | 1 | 0% | 3,082 | 9,204 | +199% | 0 | 0 | — |
case-10 | pass→pass | 17,313 | 8,230 | -52% | 1 | 1 | 0% | 2,786 | 9,479 | +240% | 0 | 0 | — |
case-05 | pass→pass | 10,645 | 8,038 | -24% | 1 | 1 | 0% | 1,785 | 9,302 | +421% | 0 | 0 | — |
case-06 | pass→pass | 10,537 | 7,894 | -25% | 1 | 1 | 0% | 1,718 | 9,236 | +438% | 0 | 0 | — |
case-07 | pass→pass | 7,138 | 4,819 | -32% | 1 | 1 | 0% | 1,248 | 8,760 | +602% | 0 | 0 | — |
case-08 | pass→pass | 10,073 | 4,589 | -54% | 1 | 1 | 0% | 1,672 | 8,727 | +422% | 0 | 0 | — |
case-09 | pass→pass | 7,940 | 24,899 | +214% | 1 | 1 | 0% | 1,322 | 8,760 | +563% | 0 | 0 | — |
case-16 | pass→pass | 15,827 | 6,964 | -56% | 1 | 1 | 0% | 2,421 | 9,110 | +276% | 0 | 0 | — |
case-11 | pass→pass | 5,853 | 5,665 | -3% | 1 | 1 | 0% | 1,014 | 8,931 | +781% | 0 | 0 | — |
case-12 | pass→pass | 13,982 | 8,680 | -38% | 1 | 1 | 0% | 2,433 | 9,438 | +288% | 0 | 0 | — |
case-13 | pass→fail | 10,159 | 3,669 | -64% | 1 | 1 | 0% | 1,572 | 8,505 | +441% | 0 | 0 | — |
case-14 | pass→pass | 8,628 | 5,084 | -41% | 1 | 1 | 0% | 1,465 | 8,815 | +502% | 0 | 0 | — |
case-15 | pass→pass | 10,705 | 4,957 | -54% | 1 | 1 | 0% | 1,912 | 8,834 | +362% | 0 | 0 | — |
case-17 | pass→pass | 8,662 | 6,658 | -23% | 1 | 1 | 0% | 1,479 | 9,096 | +515% | 0 | 0 | — |
case-18 | pass→pass | 4,298 | 3,899 | -9% | 1 | 1 | 0% | 705 | 8,622 | +1123% | 0 | 0 | — |
case-19 | pass→pass | 9,725 | 4,523 | -53% | 1 | 1 | 0% | 1,722 | 8,641 | +402% | 0 | 0 | — |
case-20 | pass→pass | 7,103 | 5,579 | -21% | 1 | 1 | 0% | 1,279 | 8,938 | +599% | 0 | 0 | — |
case-21 | fail→pass | 8,535 | 6,520 | -24% | 1 | 1 | 0% | 1,505 | 9,172 | +509% | 0 | 0 | — |
case-22 | pass→pass | 15,603 | 13,557 | -13% | 1 | 1 | 0% | 2,476 | 10,156 | +310% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +5 percentage points is the difference between those two pass rates over the 22 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.