Claude Skill

alterlab-anndata

Build, slice, concatenate, read, and write AnnData annotated data matrices (obs, var, X, layers, obsm, uns) — the scverse data STRUCTURE, not an analysis pipeline. Use when creating or wrangling .h5ad/zarr files, managing cell and gene annotations, concatenating batches, or handl

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download alterlab-ieu-alterlab-academic-skills-skills_bioinformatics_alterlab-anndata-e4836c0.zip · 26 KB
Part of alterlab-ieu/alterlab-academic-skills — 94 skills

Install

skills CLI npx skills add https://github.com/AlterLab-IEU/AlterLab-Academic-Skills/tree/main/skills/bioinformatics/alterlab-anndata
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install alterlab-ieu-alterlab-academic-skills@llmmart
Git git clone https://github.com/AlterLab-IEU/AlterLab-Academic-Skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole alterlab-ieu/alterlab-academic-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

AnnData

Overview

AnnData is a Python package for handling annotated data matrices, storing experimental measurements (X) alongside observation metadata (obs), variable metadata (var), and multi-dimensional annotations (obsm, varm, obsp, varp, uns). Originally designed for single-cell genomics through Scanpy, it now serves as a general-purpose framework for any annotated data requiring efficient storage, manipulation, and analysis.

When to Use This Skill

Use this skill when:

  • Creating, reading, or writing AnnData objects
  • Working with h5ad, zarr, or other genomics data formats
  • Performing single-cell RNA-seq analysis
  • Managing large datasets with sparse matrices or backed mode
  • Concatenating multiple datasets or experimental batches
  • Subsetting, filtering, or transforming annotated data
  • Integrating with scanpy, scvi-tools, or other scverse ecosystem tools

Does NOT Trigger

Scenario Use Instead
QC, normalization, HVG, clustering, UMAP, or marker genes alterlab-scanpy
RNA velocity from spliced/unspliced layers alterlab-scvelo
Deep generative models / batch integration (scVI, scANVI) alterlab-scvi-tools
Querying the public CELLxGENE Census for reference cells alterlab-cellxgene
Chunked n-dimensional arrays outside the AnnData model alterlab-zarr

Installation

uv pip install anndata          # 0.13.x as of 2026-09; needs Python >= 3.12

# Lazy/backed reads (ad.experimental.read_lazy) need dask + xarray:
uv pip install 'anndata[lazy]'   # or 'anndata[dask]' for dask-backed arrays only

What changed in 0.13 (breaking)

These bite existing scripts, so check them before running older code:

  • AnnData.concatenate() is gone — use the module-level ad.concat([...]).
  • .X is copy-on-write like layers/obsm: writing into a subset (view.X = 0) no longer propagates to the parent object. .X is now stored as layers[None], so it shows up in the repr and in .layers.keys().
  • The dtype= argument to the AnnData constructor was removed — cast X yourself.
  • zarr v3 only (zarr >= 3.1); writing the v2 format is deprecated as of 0.13.4 and goes away in 0.14. Stores are written sharded by default.
  • anndata.__version__ and the adata.*_keys() methods are deprecated — use importlib.metadata.version("anndata") and sorted(adata.obs) / k in adata.uns.
  • Loom I/O is deprecated (ad.io.read_loom, adata.write_loom) in favour of h5ad/zarr.

Quick Start

Creating an AnnData object

import anndata as ad
import numpy as np
import pandas as pd

# Minimal creation
X = np.random.rand(100, 2000)  # 100 cells × 2000 genes
adata = ad.AnnData(X)

# With metadata
obs = pd.DataFrame({
    'cell_type': ['T cell', 'B cell'] * 50,
    'sample': ['A', 'B'] * 50
}, index=[f'cell_{i}' for i in range(100)])

var = pd.DataFrame({
    'gene_name': [f'Gene_{i}' for i in range(2000)]
}, index=[f'ENSG{i:05d}' for i in range(2000)])

adata = ad.AnnData(X=X, obs=obs, var=var)

Reading data

import scanpy as sc  # 10x readers live in scanpy, not anndata

# Read h5ad file
adata = ad.read_h5ad('data.h5ad')

# Read with backed mode (for large files)
adata = ad.read_h5ad('large_data.h5ad', backed='r')

# Read other formats (these live under ad.io as of anndata 0.11)
adata = ad.io.read_csv('data.csv')
adata = ad.io.read_mtx('matrix.mtx')
adata = sc.read_10x_h5('filtered_feature_bc_matrix.h5')

API namespaces (anndata >= 0.11): all format readers/writers live in the anndata.io module (ad.io.read_csv, ad.io.read_mtx, ad.io.read_loom, ad.io.read_elem, ...). The top-level ad.read_csv-style aliases still resolve but emit a FutureWarning (upgraded from DeprecationWarning in 0.12), and anndata.read was removed in 0.12. Exceptions: ad.read_h5ad, ad.read_zarr, adata.write_h5ad, and adata.write_zarr stay top-level with no warning.

10x readers (read_10x_h5, read_10x_mtx) live in scanpy (sc.read_10x_h5), not anndata — this skill defers analysis-specific I/O to scanpy.

Writing data

# Write h5ad file
adata.write_h5ad('output.h5ad')

# Write with compression
adata.write_h5ad('output.h5ad', compression='gzip')

# Write other formats
adata.write_zarr('output.zarr')                  # zarr v3, sharded by default
adata.write_csvs('output_dir/', skip_data=False)  # skip_data defaults to True (obs/var only)

Basic operations

# Subset by conditions
t_cells = adata[adata.obs['cell_type'] == 'T cell']

# Subset by indices
subset = adata[0:50, 0:100]

# Add metadata
adata.obs['quality_score'] = np.random.rand(adata.n_obs)
adata.var['highly_variable'] = np.random.rand(adata.n_vars) > 0.8

# Access dimensions
print(f"{adata.n_obs} observations × {adata.n_vars} variables")

Core Capabilities

1. Data Structure

Understand the AnnData object structure including X, obs, var, layers, obsm, varm, obsp, varp, uns, and raw components.

See: references/data_structure.md for comprehensive information on:

  • Core components (X, obs, var, layers, obsm, varm, obsp, varp, uns, raw)
  • Creating AnnData objects from various sources
  • Accessing and manipulating data components
  • Memory-efficient practices

2. Input/Output Operations

Read and write data in various formats with support for compression, backed mode, and cloud storage.

See: references/io_operations.md for details on:

  • Native formats (h5ad, zarr)
  • Alternative formats (CSV, MTX, Loom, 10X, Excel)
  • Backed mode for large datasets
  • Remote data access
  • Format conversion
  • Performance optimization

Common commands:

# Read/write h5ad
adata = ad.read_h5ad('data.h5ad', backed='r')
adata.write_h5ad('output.h5ad', compression='gzip')

# Read 10X data (10x readers live in scanpy, not anndata)
import scanpy as sc
adata = sc.read_10x_h5('filtered_feature_bc_matrix.h5')

# Read MTX format (.mtx is variables x observations; transpose so cells are rows)
adata = ad.io.read_mtx('matrix.mtx').T

3. Concatenation

Combine multiple AnnData objects along observations or variables with flexible join strategies.

See: references/concatenation.md for comprehensive coverage of:

  • Basic concatenation (axis=0 for observations, axis=1 for variables)
  • Join types (inner, outer)
  • Merge strategies (same, unique, first, only)
  • Tracking data sources with labels
  • Lazy concatenation (AnnCollection)
  • On-disk concatenation for large datasets

Common commands:

# Concatenate observations (combine samples)
adata = ad.concat(
    [adata1, adata2, adata3],
    axis=0,
    join='inner',
    label='batch',
    keys=['batch1', 'batch2', 'batch3']
)

# Concatenate variables (combine modalities)
adata = ad.concat([adata_rna, adata_protein], axis=1)

# Lazy concatenation — AnnCollection takes AnnData objects (open them backed), not paths
from anndata.experimental import AnnCollection
adatas = {p: ad.read_h5ad(f'{p}.h5ad', backed='r') for p in ('data1', 'data2')}
collection = AnnCollection(adatas, join_obs='outer', label='dataset')

4. Data Manipulation

Transform, subset, filter, and reorganize data efficiently.

See: references/manipulation.md for detailed guidance on:

  • Subsetting (by indices, names, boolean masks, metadata conditions)
  • Transposition
  • Copying (full copies vs views)
  • Renaming (observations, variables, categories)
  • Type conversions (strings to categoricals, sparse/dense)
  • Adding/removing data components
  • Reordering
  • Quality control filtering

Common commands:

# Subset by metadata
filtered = adata[adata.obs['quality_score'] > 0.8]
hv_genes = adata[:, adata.var['highly_variable']]

# Transpose
adata_T = adata.T

# Copy vs view
view = adata[0:100, :]  # View (lightweight reference; writes to it copy-on-write in 0.13+)
copy = adata[0:100, :].copy()  # Independent copy

# Convert strings to categoricals
adata.strings_to_categoricals()

5. Best Practices

Follow recommended patterns for memory efficiency, performance, and reproducibility.

See: references/best_practices.md for guidelines on:

  • Memory management (sparse matrices, categoricals, backed mode)
  • Views vs copies
  • Data storage optimization
  • Performance optimization
  • Working with raw data
  • Metadata management
  • Reproducibility
  • Error handling
  • Integration with other tools
  • Common pitfalls and solutions

Key recommendations:

# Use sparse matrices for sparse data
from scipy.sparse import csr_matrix
adata.X = csr_matrix(adata.X)

# Convert strings to categoricals
adata.strings_to_categoricals()

# Use backed mode for large files
adata = ad.read_h5ad('large.h5ad', backed='r')

# Store raw before filtering
adata.raw = adata.copy()
adata = adata[:, adata.var['highly_variable']]

Integration with Scverse Ecosystem

AnnData serves as the foundational data structure for the scverse ecosystem:

Scanpy (Single-cell analysis)

AnnData is scanpy's native object — once built/loaded, pass it straight in. Preprocessing, dimensionality reduction, clustering, and plotting (sc.pp.normalize_total, sc.pp.highly_variable_genes, sc.pp.pca, sc.pp.neighbors, sc.tl.umap, sc.tl.leiden, sc.pl.*) are scanpy's job, not anndata's — defer the analysis workflow there.

import scanpy as sc

sc.pp.filter_cells(adata, min_genes=200)  # scanpy mutates the AnnData in place
# ... continue the analysis pipeline in scanpy

Muon (Multimodal data)

import muon as mu

# Combine RNA and protein data
mdata = mu.MuData({'rna': adata_rna, 'protein': adata_protein})

PyTorch integration

from anndata.experimental import AnnLoader

# Create DataLoader for deep learning (also accepts an AnnCollection)
dataloader = AnnLoader(adata, batch_size=128, shuffle=True)

for batch in dataloader:
    X = batch["X"]      # dict-style access; tensors, not attributes
    labels = batch["obs"]["cell_type"]
    # Train model

Common Workflows

Single-cell data lifecycle (the anndata-owned parts)

Load, compute simple QC metrics on obs/var, snapshot raw, subset, and write. The normalize/log1p/HVG/cluster steps belong to scanpy — hand off there.

import anndata as ad
import scanpy as sc

# 1. Load (10x readers live in scanpy)
adata = sc.read_10x_h5('filtered_feature_bc_matrix.h5')

# 2. Quick QC metrics on obs/var, then mask-subset (pure anndata wrangling)
adata.obs['n_genes'] = (adata.X > 0).sum(axis=1)
adata.obs['n_counts'] = adata.X.sum(axis=1)
adata = adata[(adata.obs['n_genes'] > 200) & (adata.obs['n_counts'] < 50000)].copy()

# 3. Snapshot raw before any gene filtering
adata.raw = adata.copy()

# 4. Hand off normalization / HVG / clustering to scanpy, then come back:
#    sc.pp.normalize_total / sc.pp.log1p / sc.pp.highly_variable_genes / ...
adata = adata[:, adata.var['highly_variable']].copy()  # subset is anndata's job

# 5. Save processed data
adata.write_h5ad('processed.h5ad', compression='gzip')

Batch integration (concatenate, then defer correction)

# Load and concatenate batches with source labels — this is anndata's job
adatas = [ad.read_h5ad(p) for p in ['batch1.h5ad', 'batch2.h5ad', 'batch3.h5ad']]
adata = ad.concat(
    adatas,
    label='batch',
    keys=['batch1', 'batch2', 'batch3'],
    join='inner',
)

# Batch correction and downstream analysis (combat / pca / neighbors / umap)
# are scanpy territory — pass `adata` to scanpy from here.

Working with large datasets

# Open in backed mode
adata = ad.read_h5ad('100GB_dataset.h5ad', backed='r')

# Filter based on metadata (no data loading)
high_quality = adata[adata.obs['quality_score'] > 0.8]

# Load filtered subset
adata_subset = high_quality.to_memory()

# Process subset
process(adata_subset)

# Or process in chunks
chunk_size = 1000
for i in range(0, adata.n_obs, chunk_size):
    chunk = adata[i:i+chunk_size, :].to_memory()
    process(chunk)

Troubleshooting

Out of memory errors

Use backed mode or convert to sparse matrices:

# Backed mode
adata = ad.read_h5ad('file.h5ad', backed='r')

# Sparse matrices
from scipy.sparse import csr_matrix
adata.X = csr_matrix(adata.X)

Slow file reading

Use compression and appropriate formats:

# Optimize for storage
adata.strings_to_categoricals()
adata.write_h5ad('file.h5ad', compression='gzip')

# Use Zarr for cloud storage
adata.write_zarr('file.zarr', chunks=(1000, 1000))

Index alignment issues

Always align external data on index:

# Wrong
adata.obs['new_col'] = external_data['values']

# Correct
adata.obs['new_col'] = external_data.set_index('cell_id').loc[adata.obs_names, 'values']

Additional Resources

Part of the AlterLab Academic Skills suite.

Files (alterlab-academic-skills)
  • evals
    • evals.json 4.7 KB
      {
        "skill": "alterlab-anndata",
        "evals": [
          {
            "id": "concat-batches-inner-join",
            "prompt": "I have three .h5ad files from separate sequencing runs (batch1.h5ad, batch2.h5ad, batch3.h5ad). I want to combine them into one AnnData object along the cells axis, keep only the genes shared across all three, and add a 'batch' column so I can tell where each cell came from.",
            "expected_output": "Invokes alterlab-anndata: uses ad.concat with axis=0, join='inner', and label/keys to tag the batch source, reading each file with ad.read_h5ad first. Stays in data-wrangling territory (concatenation), does not jump into a full scanpy analysis pipeline.",
            "assertions": [
              { "type": "should_trigger", "value": true },
              { "type": "output_contains", "value": "concat" },
              { "type": "behavior", "value": "Uses join='inner' to keep shared genes and a label/keys argument to record the batch of origin." }
            ]
          },
          {
            "id": "backed-mode-large-h5ad",
            "prompt": "My .h5ad file is about 90 GB and won't fit in my 48 GB of RAM. I just need to filter to cells where obs['quality_score'] > 0.8 and then pull that subset into memory. How do I do this without loading the whole thing?",
            "expected_output": "Invokes alterlab-anndata: opens the file with ad.read_h5ad(..., backed='r'), filters on the obs metadata mask without loading X, then materializes the subset with .to_memory() (or chunked iteration). Memory-efficient backed-mode pattern.",
            "assertions": [
              { "type": "should_trigger", "value": true },
              { "type": "output_contains", "value": "backed" },
              { "type": "behavior", "value": "Recommends backed='r' read and .to_memory() (or chunked reads) rather than a full in-memory load." }
            ]
          },
          {
            "id": "build-anndata-from-matrix",
            "prompt": "I have a numpy expression matrix (cells x genes), a pandas DataFrame of cell annotations, and a DataFrame of gene info. How do I assemble these into a proper AnnData object and write it out as a compressed .h5ad?",
            "expected_output": "Invokes alterlab-anndata: constructs ad.AnnData(X=..., obs=..., var=...) ensuring index alignment, optionally converts strings to categoricals, and writes with adata.write_h5ad('out.h5ad', compression='gzip'). Pure object construction and I/O.",
            "assertions": [
              { "type": "should_trigger", "value": true },
              { "type": "output_contains", "value": "AnnData" },
              { "type": "behavior", "value": "Builds the object via ad.AnnData(X, obs, var) and writes h5ad with gzip compression." }
            ]
          },
          {
            "id": "combine-rna-protein-modalities",
            "prompt": "I have two AnnData objects on the same cells - one with RNA expression and one with surface protein (ADT) counts. I want to stack them so each cell carries both modalities' features in a single matrix.",
            "expected_output": "Invokes alterlab-anndata: uses ad.concat([adata_rna, adata_protein], axis=1) to concatenate along variables (features). May note muon/MuData as the dedicated multimodal container. Stays a concatenation/format task.",
            "assertions": [
              { "type": "should_trigger", "value": true },
              { "type": "behavior", "value": "Concatenates along axis=1 (variables) to combine modalities, not axis=0." }
            ]
          },
          {
            "id": "near-miss-scanpy",
            "prompt": "I've loaded my .h5ad into an AnnData object. Now run the standard preprocessing - normalize total counts, log1p, find the top 2000 highly variable genes, run PCA, build the neighbor graph, and give me Leiden clusters on a UMAP.",
            "expected_output": "Should NOT trigger alterlab-anndata. This is the established single-cell analysis pipeline (normalize_total, log1p, highly_variable_genes, pca, neighbors, leiden, umap) which is alterlab-scanpy territory. anndata is the data-format skill and defers the analysis workflow to scanpy.",
            "assertions": [
              { "type": "should_not_trigger", "value": true },
              { "type": "output_contains", "value": "scanpy" }
            ]
          },
          {
            "id": "near-miss-cellxgene",
            "prompt": "I want to pull a reference set of human lung B cells from the CZ CELLxGENE Census so I can benchmark my own dataset against a curated public atlas. Filter by tissue and cell type and return it as AnnData.",
            "expected_output": "Should NOT trigger alterlab-anndata. Querying the population-scale CZ CELLxGENE Census (cellxgene_census.get_anndata, obs_value_filter, TileDB-SOMA) is alterlab-cellxgene territory. anndata handles local objects and defers public-atlas queries to cellxgene-census, even though the result is an AnnData.",
            "assertions": [
              { "type": "should_not_trigger", "value": true },
              { "type": "output_contains", "value": "cellxgene" }
            ]
          }
        ]
      }
      
  • references
    • best_practices.md 12.4 KB
      # Best Practices
      
      Guidelines for efficient and effective use of AnnData.
      
      ## Memory Management
      
      ### Use sparse matrices for sparse data
      ```python
      import numpy as np
      from scipy.sparse import csr_matrix
      import anndata as ad
      
      # Check data sparsity
      data = np.random.rand(1000, 2000)
      sparsity = 1 - np.count_nonzero(data) / data.size
      print(f"Sparsity: {sparsity:.2%}")
      
      # Convert to sparse if >50% zeros
      if sparsity > 0.5:
          adata = ad.AnnData(X=csr_matrix(data))
      else:
          adata = ad.AnnData(X=data)
      
      # Benefits: 10-100x memory reduction for sparse genomics data
      ```
      
      ### Convert strings to categoricals
      ```python
      # Inefficient: string columns use lots of memory
      adata.obs['cell_type'] = ['Type_A', 'Type_B', 'Type_C'] * 333 + ['Type_A']
      
      # Efficient: convert to categorical
      adata.obs['cell_type'] = adata.obs['cell_type'].astype('category')
      
      # Convert all string columns
      adata.strings_to_categoricals()
      
      # Benefits: 10-50x memory reduction for repeated strings
      ```
      
      ### Use backed mode for large datasets
      ```python
      # Don't load entire dataset into memory
      adata = ad.read_h5ad('large_dataset.h5ad', backed='r')
      
      # Work with metadata
      filtered = adata[adata.obs['quality'] > 0.8]
      
      # Load only filtered subset
      adata_subset = filtered.to_memory()
      
      # Benefits: Work with datasets larger than RAM
      ```
      
      ## Views vs Copies
      
      ### Understanding views
      ```python
      # Subsetting creates a view by default
      subset = adata[0:100, :]
      print(subset.is_view)  # True
      
      # Views don't copy data (memory efficient). Since anndata 0.13 they are
      # copy-on-write: assigning into a view materializes a copy instead of
      # writing through to the parent object.
      
      # Check if object is a view
      if adata.is_view:
          adata = adata.copy()  # Make independent
      ```
      
      ### When to use views
      ```python
      # Good: Read-only operations on subsets
      mean_expr = adata[adata.obs['cell_type'] == 'T cell'].X.mean()
      
      # Good: Temporary analysis
      temp_subset = adata[:100, :]
      result = analyze(temp_subset.X)
      ```
      
      ### When to use copies
      ```python
      # Create independent copy for modifications
      adata_filtered = adata[keep_cells, :].copy()
      
      # Safe to modify without affecting original
      adata_filtered.obs['new_column'] = values
      
      # Always copy when:
      # - Storing subset for later use
      # - Modifying subset data
      # - Passing to function that modifies data
      ```
      
      ## Data Storage Best Practices
      
      ### Choose the right format
      
      **H5AD (HDF5) - Default choice**
      ```python
      adata.write_h5ad('data.h5ad', compression='gzip')
      ```
      - Fast random access
      - Supports backed mode
      - Good compression
      - Best for: Most use cases
      
      **Zarr - Cloud and parallel access**
      ```python
      adata.write_zarr('data.zarr', chunks=(100, 100))
      ```
      - Excellent for cloud storage (S3, GCS)
      - Supports parallel I/O
      - Good compression
      - Best for: Large datasets, cloud workflows, parallel processing
      
      **CSV - Interoperability**
      ```python
      adata.write_csvs('output_dir/')
      ```
      - Human readable
      - Compatible with all tools
      - Large file sizes, slow
      - Best for: Sharing with non-Python tools, small datasets
      
      ### Optimize file size
      ```python
      # Before saving, optimize:
      
      # 1. Convert to sparse if appropriate
      from scipy.sparse import csr_matrix, issparse
      if not issparse(adata.X):
          density = np.count_nonzero(adata.X) / adata.X.size
          if density < 0.5:
              adata.X = csr_matrix(adata.X)
      
      # 2. Convert strings to categoricals
      adata.strings_to_categoricals()
      
      # 3. Use compression
      adata.write_h5ad('data.h5ad', compression='gzip', compression_opts=9)
      
      # Typical results: 5-20x file size reduction
      ```
      
      ## Backed Mode Strategies
      
      ### Read-only analysis
      ```python
      # Open in read-only backed mode
      adata = ad.read_h5ad('data.h5ad', backed='r')
      
      # Perform filtering without loading data
      high_quality = adata[adata.obs['quality_score'] > 0.8]
      
      # Load only filtered data
      adata_filtered = high_quality.to_memory()
      ```
      
      ### Read-write modifications
      ```python
      # Open in read-write backed mode
      adata = ad.read_h5ad('data.h5ad', backed='r+')
      
      # Modify metadata (written to disk)
      adata.obs['new_annotation'] = values
      
      # X remains on disk, modifications saved immediately
      ```
      
      ### Chunked processing
      ```python
      # Process large dataset in chunks
      adata = ad.read_h5ad('huge_dataset.h5ad', backed='r')
      
      results = []
      chunk_size = 1000
      
      for i in range(0, adata.n_obs, chunk_size):
          chunk = adata[i:i+chunk_size, :].to_memory()
          result = process(chunk)
          results.append(result)
      
      final_result = combine(results)
      ```
      
      ## Performance Optimization
      
      ### Subsetting performance
      ```python
      # Boolean masks and integer indices both work; pass a numpy array (not a
      # pandas Series) to avoid index-alignment surprises.
      mask = (adata.obs['quality'] > 0.5).to_numpy()
      subset = adata[mask, :]
      
      # Equivalent via integer positions
      indices = np.where(mask)[0]
      subset = adata[indices, :]
      ```
      Subsetting returns a lightweight **view** — call `.copy()` when you need an
      independent object (see Views vs Copies above).
      
      ### Avoid repeated subsetting
      ```python
      # Inefficient: Multiple subset operations
      for cell_type in ['A', 'B', 'C']:
          subset = adata[adata.obs['cell_type'] == cell_type]
          process(subset)
      
      # Efficient: Group and process
      groups = adata.obs.groupby('cell_type').groups
      for cell_type, indices in groups.items():
          subset = adata[indices, :]
          process(subset)
      ```
      
      ### Use chunked operations for large matrices
      ```python
      # Process X in chunks
      for chunk in adata.chunked_X(chunk_size=1000):
          result = compute(chunk)
      
      # More memory efficient than loading full X
      ```
      
      ## Working with Raw Data
      
      ### Store raw before filtering
      ```python
      # Original data with all genes
      adata = ad.AnnData(X=counts)
      
      # Store raw before filtering
      adata.raw = adata.copy()
      
      # Filter to highly variable genes
      adata = adata[:, adata.var['highly_variable']]
      
      # Later: access original data
      original_expression = adata.raw.X
      all_genes = adata.raw.var_names
      ```
      
      ### When to use raw
      ```python
      # Use raw for:
      # - Differential expression on filtered genes
      # - Visualization of specific genes not in filtered set
      # - Accessing original counts after normalization
      
      # Access raw data
      if adata.raw is not None:
          gene_expr = adata.raw[:, 'GENE_NAME'].X
      else:
          gene_expr = adata[:, 'GENE_NAME'].X
      ```
      
      ## Metadata Management
      
      ### Naming conventions
      ```python
      # Consistent naming improves usability
      
      # Observation metadata (obs):
      # - cell_id, sample_id
      # - cell_type, tissue, condition
      # - n_genes, n_counts, percent_mito
      # - cluster, leiden, louvain
      
      # Variable metadata (var):
      # - gene_id, gene_name
      # - highly_variable, n_cells
      # - mean_expression, dispersion
      
      # Embeddings (obsm):
      # - X_pca, X_umap, X_tsne
      # - X_diffmap, X_draw_graph_fr
      
      # Follow conventions from scanpy/scverse ecosystem
      ```
      
      ### Document metadata
      ```python
      # Store metadata descriptions in uns
      adata.uns['metadata_descriptions'] = {
          'cell_type': 'Cell type annotation from automated clustering',
          'quality_score': 'QC score from scrublet (0-1, higher is better)',
          'batch': 'Experimental batch identifier'
      }
      
      # Store processing history
      adata.uns['processing_steps'] = [
          'Raw counts loaded from 10X',
          'Filtered: n_genes > 200, n_counts < 50000',
          'Normalized to 10000 counts per cell',
          'Log transformed'
      ]
      ```
      
      ## Reproducibility
      
      ### Set random seeds
      ```python
      import numpy as np
      
      # Set seed for reproducible results
      np.random.seed(42)
      
      # Document in uns
      adata.uns['random_seed'] = 42
      ```
      
      ### Store parameters
      ```python
      # Store analysis parameters in uns
      adata.uns['pca'] = {
          'n_comps': 50,
          'svd_solver': 'arpack',
          'random_state': 42
      }
      
      adata.uns['neighbors'] = {
          'n_neighbors': 15,
          'n_pcs': 50,
          'metric': 'euclidean',
          'method': 'umap'
      }
      ```
      
      ### Version tracking
      ```python
      import sys
      
      # anndata.__version__ is deprecated in 0.13 — read versions from the metadata API
      from importlib.metadata import version
      
      adata.uns['versions'] = {
          'anndata': version('anndata'),
          'scanpy': version('scanpy'),
          'numpy': version('numpy'),
          'python': sys.version,
      }
      ```
      
      ## Error Handling
      
      ### Check data validity
      ```python
      # Verify dimensions
      assert adata.n_obs == len(adata.obs)
      assert adata.n_vars == len(adata.var)
      assert adata.X.shape == (adata.n_obs, adata.n_vars)
      
      # Check for NaN values
      has_nan = np.isnan(adata.X.data).any() if issparse(adata.X) else np.isnan(adata.X).any()
      if has_nan:
          print("Warning: Data contains NaN values")
      
      # Check for negative values (if counts expected)
      has_negative = (adata.X.data < 0).any() if issparse(adata.X) else (adata.X < 0).any()
      if has_negative:
          print("Warning: Data contains negative values")
      ```
      
      ### Validate metadata
      ```python
      # Check for missing values
      missing_obs = adata.obs.isnull().sum()
      if missing_obs.any():
          print("Missing values in obs:")
          print(missing_obs[missing_obs > 0])
      
      # Verify indices are unique
      assert adata.obs_names.is_unique, "Observation names not unique"
      assert adata.var_names.is_unique, "Variable names not unique"
      
      # Check metadata alignment
      assert len(adata.obs) == adata.n_obs
      assert len(adata.var) == adata.n_vars
      ```
      
      ## Integration with Other Tools
      
      ### Scanpy integration
      ```python
      import scanpy as sc
      
      # AnnData is native format for scanpy
      sc.pp.filter_cells(adata, min_genes=200)
      sc.pp.filter_genes(adata, min_cells=3)
      sc.pp.normalize_total(adata, target_sum=1e4)
      sc.pp.log1p(adata)
      sc.pp.highly_variable_genes(adata)
      sc.pp.pca(adata)
      sc.pp.neighbors(adata)
      sc.tl.umap(adata)
      ```
      
      ### Pandas integration
      ```python
      import pandas as pd
      
      # Convert to DataFrame
      df = adata.to_df()
      
      # Create from DataFrame
      adata = ad.AnnData(df)
      
      # Work with metadata as DataFrames
      adata.obs = adata.obs.merge(external_metadata, left_index=True, right_index=True)
      ```
      
      ### PyTorch integration
      ```python
      from anndata.experimental import AnnLoader
      
      # Create PyTorch DataLoader (accepts an AnnData or an AnnCollection)
      dataloader = AnnLoader(adata, batch_size=128, shuffle=True)
      
      # Iterate in training loop — batches are dict-like with tensors
      for batch in dataloader:
          X = batch["X"]      # torch.Tensor
          # Train model on batch
      ```
      
      ## Common Pitfalls
      
      ### Pitfall 1: Expecting writes to a view to reach the parent
      ```python
      # anndata >= 0.13: .X is copy-on-write like layers/obsm, so this does NOT
      # change adata.X — the view silently materializes its own copy instead.
      subset = adata[:100, :]
      subset.X = new_data          # parent untouched (older versions could propagate)
      
      # Be explicit about which object you mean to modify:
      subset = adata[:100, :].copy()   # independent copy
      adata.X[:100] = new_data         # or write into the parent's array directly
      ```
      
      ### Pitfall 2: Index misalignment
      ```python
      # Wrong: Assuming order matches
      external_data = pd.read_csv('data.csv')
      adata.obs['new_col'] = external_data['values']  # May misalign!
      
      # Correct: Align on index
      adata.obs['new_col'] = external_data.set_index('cell_id').loc[adata.obs_names, 'values']
      ```
      
      ### Pitfall 3: Mixing sparse and dense
      ```python
      # Wrong: Converting sparse to dense uses huge memory
      result = adata.X + 1  # Converts sparse to dense!
      
      # Correct: Use sparse operations
      from scipy.sparse import issparse
      if issparse(adata.X):
          result = adata.X.copy()
          result.data += 1
      ```
      
      ### Pitfall 4: Not handling views
      ```python
      # Wrong: Assuming subset is independent
      subset = adata[mask, :]
      del adata  # subset may become invalid!
      
      # Correct: Copy when needed
      subset = adata[mask, :].copy()
      del adata  # subset remains valid
      ```
      
      ### Pitfall 5: Ignoring memory constraints
      ```python
      # Wrong: Loading huge dataset into memory
      adata = ad.read_h5ad('100GB_file.h5ad')  # OOM error!
      
      # Correct: Use backed mode
      adata = ad.read_h5ad('100GB_file.h5ad', backed='r')
      subset = adata[adata.obs['keep']].to_memory()
      ```
      
      ## Workflow Example
      
      Complete best-practices workflow:
      
      ```python
      import anndata as ad
      import numpy as np
      from scipy.sparse import csr_matrix
      
      # 1. Load with backed mode if large
      adata = ad.read_h5ad('data.h5ad', backed='r')
      
      # 2. Quick metadata check without loading data
      print(f"Dataset: {adata.n_obs} cells × {adata.n_vars} genes")
      
      # 3. Filter based on metadata
      high_quality = adata[adata.obs['quality_score'] > 0.8]
      
      # 4. Load filtered subset to memory
      adata = high_quality.to_memory()
      
      # 5. Convert to optimal storage types
      adata.strings_to_categoricals()
      if not issparse(adata.X):
          density = np.count_nonzero(adata.X) / adata.X.size
          if density < 0.5:
              adata.X = csr_matrix(adata.X)
      
      # 6. Store raw before filtering genes
      adata.raw = adata.copy()
      
      # 7. Filter to highly variable genes
      adata = adata[:, adata.var['highly_variable']].copy()
      
      # 8. Document processing
      adata.uns['processing'] = {
          'filtered': 'quality_score > 0.8',
          'n_hvg': adata.n_vars,
          'date': '2025-11-03'
      }
      
      # 9. Save optimized
      adata.write_h5ad('processed.h5ad', compression='gzip')
      ```
      
    • concatenation.md 10.4 KB
      # Concatenating AnnData Objects
      
      Combine multiple AnnData objects along either observations or variables axis.
      
      ## Basic Concatenation
      
      ### Concatenate along observations (stack cells/samples)
      ```python
      import anndata as ad
      import numpy as np
      
      # Create multiple AnnData objects
      adata1 = ad.AnnData(X=np.random.rand(100, 50))
      adata2 = ad.AnnData(X=np.random.rand(150, 50))
      adata3 = ad.AnnData(X=np.random.rand(200, 50))
      
      # Concatenate along observations (axis=0, default)
      adata_combined = ad.concat([adata1, adata2, adata3], axis=0)
      
      print(adata_combined.shape)  # (450, 50)
      ```
      
      ### Concatenate along variables (stack genes/features)
      ```python
      # Create objects with same observations, different variables
      adata1 = ad.AnnData(X=np.random.rand(100, 50))
      adata2 = ad.AnnData(X=np.random.rand(100, 30))
      adata3 = ad.AnnData(X=np.random.rand(100, 70))
      
      # Concatenate along variables (axis=1)
      adata_combined = ad.concat([adata1, adata2, adata3], axis=1)
      
      print(adata_combined.shape)  # (100, 150)
      ```
      
      ## Join Types
      
      ### Inner join (intersection)
      Keep only variables/observations present in all objects.
      
      ```python
      import pandas as pd
      
      # Create objects with different variables
      adata1 = ad.AnnData(
          X=np.random.rand(100, 50),
          var=pd.DataFrame(index=[f'Gene_{i}' for i in range(50)])
      )
      adata2 = ad.AnnData(
          X=np.random.rand(150, 60),
          var=pd.DataFrame(index=[f'Gene_{i}' for i in range(10, 70)])
      )
      
      # Inner join: only genes 10-49 are kept (overlap)
      adata_inner = ad.concat([adata1, adata2], join='inner')
      print(adata_inner.n_vars)  # 40 genes (overlap)
      ```
      
      ### Outer join (union)
      Keep all variables/observations, filling missing values.
      
      ```python
      # Outer join: all genes are kept
      adata_outer = ad.concat([adata1, adata2], join='outer')
      print(adata_outer.n_vars)  # 70 genes (union)
      
      # Missing values are filled with appropriate defaults:
      # - 0 for sparse matrices
      # - NaN for dense matrices
      ```
      
      ### Fill values for outer joins
      ```python
      # Specify fill value for missing data
      adata_filled = ad.concat([adata1, adata2], join='outer', fill_value=0)
      ```
      
      ## Tracking Data Sources
      
      ### Add batch labels
      ```python
      # Label which object each observation came from
      adata_combined = ad.concat(
          [adata1, adata2, adata3],
          label='batch',  # Column name for labels
          keys=['batch1', 'batch2', 'batch3']  # Labels for each object
      )
      
      print(adata_combined.obs['batch'].value_counts())
      # batch1    100
      # batch2    150
      # batch3    200
      ```
      
      ### Automatic batch labels
      ```python
      # If keys not provided, uses integer indices
      adata_combined = ad.concat(
          [adata1, adata2, adata3],
          label='dataset'
      )
      # dataset column contains: 0, 1, 2
      ```
      
      ## Merge Strategies
      
      Control how metadata from different objects is combined using the `merge` parameter.
      
      ### merge=None (default for observations)
      Exclude metadata on non-concatenation axis.
      
      ```python
      # When concatenating observations, var metadata must match
      adata1.var['gene_type'] = 'protein_coding'
      adata2.var['gene_type'] = 'protein_coding'
      
      # var is kept only if identical across all objects
      adata_combined = ad.concat([adata1, adata2], merge=None)
      ```
      
      ### merge='same'
      Keep metadata that is identical across all objects.
      
      ```python
      adata1.var['chromosome'] = ['chr1'] * 25 + ['chr2'] * 25
      adata2.var['chromosome'] = ['chr1'] * 25 + ['chr2'] * 25
      adata1.var['type'] = 'protein_coding'
      adata2.var['type'] = 'lncRNA'  # Different
      
      # 'chromosome' is kept (same), 'type' is excluded (different)
      adata_combined = ad.concat([adata1, adata2], merge='same')
      ```
      
      ### merge='unique'
      Keep metadata columns where each key has exactly one value.
      
      ```python
      adata1.var['gene_id'] = [f'ENSG{i:05d}' for i in range(50)]
      adata2.var['gene_id'] = [f'ENSG{i:05d}' for i in range(50)]
      
      # gene_id is kept (unique values for each key)
      adata_combined = ad.concat([adata1, adata2], merge='unique')
      ```
      
      ### merge='first'
      Take values from the first object containing each key.
      
      ```python
      adata1.var['description'] = ['Desc1'] * 50
      adata2.var['description'] = ['Desc2'] * 50
      
      # Uses descriptions from adata1
      adata_combined = ad.concat([adata1, adata2], merge='first')
      ```
      
      ### merge='only'
      Keep metadata that appears in only one object.
      
      ```python
      adata1.var['adata1_specific'] = [1] * 50
      adata2.var['adata2_specific'] = [2] * 50
      
      # Both metadata columns are kept
      adata_combined = ad.concat([adata1, adata2], merge='only')
      ```
      
      ## Handling Index Conflicts
      
      ### Make indices unique
      ```python
      import pandas as pd
      
      # Create objects with overlapping observation names
      adata1 = ad.AnnData(
          X=np.random.rand(3, 10),
          obs=pd.DataFrame(index=['cell_1', 'cell_2', 'cell_3'])
      )
      adata2 = ad.AnnData(
          X=np.random.rand(3, 10),
          obs=pd.DataFrame(index=['cell_1', 'cell_2', 'cell_3'])
      )
      
      # Make indices unique by appending batch keys
      adata_combined = ad.concat(
          [adata1, adata2],
          label='batch',
          keys=['batch1', 'batch2'],
          index_unique='_'  # Separator for making indices unique
      )
      
      print(adata_combined.obs_names)
      # ['cell_1_batch1', 'cell_2_batch1', 'cell_3_batch1',
      #  'cell_1_batch2', 'cell_2_batch2', 'cell_3_batch2']
      ```
      
      ## Concatenating Layers
      
      ```python
      # Objects with layers
      adata1 = ad.AnnData(X=np.random.rand(100, 50))
      adata1.layers['normalized'] = np.random.rand(100, 50)
      adata1.layers['scaled'] = np.random.rand(100, 50)
      
      adata2 = ad.AnnData(X=np.random.rand(150, 50))
      adata2.layers['normalized'] = np.random.rand(150, 50)
      adata2.layers['scaled'] = np.random.rand(150, 50)
      
      # Layers are concatenated automatically if present in all objects
      adata_combined = ad.concat([adata1, adata2])
      
      print(adata_combined.layers.keys())
      # dict_keys(['normalized', 'scaled'])
      ```
      
      ## Concatenating Multi-dimensional Annotations
      
      ### obsm/varm
      ```python
      # Objects with embeddings
      adata1.obsm['X_pca'] = np.random.rand(100, 50)
      adata2.obsm['X_pca'] = np.random.rand(150, 50)
      
      # obsm is concatenated along observation axis
      adata_combined = ad.concat([adata1, adata2])
      print(adata_combined.obsm['X_pca'].shape)  # (250, 50)
      ```
      
      ### obsp/varp (pairwise annotations)
      ```python
      from scipy.sparse import csr_matrix
      
      # Pairwise matrices
      adata1.obsp['connectivities'] = csr_matrix((100, 100))
      adata2.obsp['connectivities'] = csr_matrix((150, 150))
      
      # By default, obsp is NOT concatenated (set pairwise=True to include)
      adata_combined = ad.concat([adata1, adata2])
      # adata_combined.obsp is empty
      
      # Include pairwise data (creates block diagonal matrix)
      adata_combined = ad.concat([adata1, adata2], pairwise=True)
      print(adata_combined.obsp['connectivities'].shape)  # (250, 250)
      ```
      
      ## Concatenating uns (unstructured)
      
      Unstructured metadata is merged recursively:
      
      ```python
      adata1.uns['experiment'] = {'date': '2025-01-01', 'batch': 'A'}
      adata2.uns['experiment'] = {'date': '2025-01-01', 'batch': 'B'}
      
      # Using merge='unique' for uns
      adata_combined = ad.concat([adata1, adata2], uns_merge='unique')
      # 'date' is kept (same value), 'batch' might be excluded (different values)
      ```
      
      ## Lazy Concatenation (AnnCollection)
      
      For very large datasets, wrap several AnnData objects in a virtual collection
      that behaves like one big dataset without concatenating in memory. Open each
      file `backed='r'` so X stays on disk until a batch is sliced. `AnnCollection`
      takes AnnData objects (not raw path strings).
      
      ```python
      import anndata as ad
      from anndata.experimental import AnnCollection
      
      # Open backed, then wrap (data not loaded yet)
      adatas = [ad.read_h5ad(p, backed='r') for p in ['data1.h5ad', 'data2.h5ad', 'data3.h5ad']]
      collection = AnnCollection(adatas)
      
      # Access metadata and slice lazily
      print(len(collection))           # total observations
      print(collection.obs.head())     # concatenated obs, X not loaded
      
      batch = collection[10:20]        # materializes just this slice
      print(batch.X.shape)
      ```
      
      `AnnCollection` is also the multi-file backend for `AnnLoader` (PyTorch
      mini-batch training). For an eagerly concatenated object, use `ad.concat`
      instead.
      
      ## Concatenation on Disk
      
      For datasets too large for memory, concatenate directly on disk:
      
      ```python
      from anndata.experimental import concat_on_disk
      
      # Concatenate without loading into memory; output path is the `out_file` kwarg.
      # Pass a mapping to also inject batch keys via `label`.
      concat_on_disk(
          {'b1': 'data1.h5ad', 'b2': 'data2.h5ad', 'b3': 'data3.h5ad'},
          out_file='combined.h5ad',
          label='batch',
          join='outer',
      )
      
      # Load result in backed mode
      adata = ad.read_h5ad('combined.h5ad', backed='r')
      ```
      
      ## Common Concatenation Patterns
      
      ### Combine technical replicates
      ```python
      # Multiple runs of the same samples
      replicates = [adata_run1, adata_run2, adata_run3]
      adata_combined = ad.concat(
          replicates,
          label='technical_replicate',
          keys=['rep1', 'rep2', 'rep3'],
          join='inner'  # Keep only genes measured in all runs
      )
      ```
      
      ### Combine batches from experiment
      ```python
      # Different experimental batches
      batches = [adata_batch1, adata_batch2, adata_batch3]
      adata_combined = ad.concat(
          batches,
          label='batch',
          keys=['batch1', 'batch2', 'batch3'],
          join='outer'  # Keep all genes
      )
      
      # Later: apply batch correction
      ```
      
      ### Merge multi-modal data
      ```python
      # Different measurement modalities (e.g., RNA + protein)
      adata_rna = ad.AnnData(X=np.random.rand(100, 2000))
      adata_protein = ad.AnnData(X=np.random.rand(100, 50))
      
      # Concatenate along variables to combine modalities
      adata_multimodal = ad.concat([adata_rna, adata_protein], axis=1)
      
      # Add labels to distinguish modalities
      adata_multimodal.var['modality'] = ['RNA'] * 2000 + ['protein'] * 50
      ```
      
      ## Best Practices
      
      1. **Check compatibility before concatenating**
      ```python
      # Verify shapes are compatible
      print([adata.n_vars for adata in [adata1, adata2, adata3]])
      
      # Check variable names match
      print([set(adata.var_names) for adata in [adata1, adata2, adata3]])
      ```
      
      2. **Use appropriate join type**
      - `inner`: When you need the same features across all samples (most stringent)
      - `outer`: When you want to preserve all features (most inclusive)
      
      3. **Track data sources**
      Always use `label` and `keys` to track which observations came from which dataset.
      
      4. **Consider memory usage**
      - For large datasets, use `AnnCollection` or `concat_on_disk`
      - Consider backed mode for the result
      
      5. **Handle batch effects**
      Concatenation combines data but doesn't correct for batch effects. Apply batch correction after concatenation:
      ```python
      # After concatenation, apply batch correction
      import scanpy as sc
      sc.pp.combat(adata_combined, key='batch')
      ```
      
      6. **Validate results**
      ```python
      # Check dimensions
      print(adata_combined.shape)
      
      # Check batch distribution
      print(adata_combined.obs['batch'].value_counts())
      
      # Verify metadata integrity
      print(adata_combined.var.head())
      print(adata_combined.obs.head())
      ```
      
    • data_structure.md 8.5 KB
      # AnnData Object Structure
      
      The AnnData object stores a data matrix with associated annotations, providing a flexible framework for managing experimental data and metadata.
      
      ## Core Components
      
      ### X (Data Matrix)
      The primary data matrix with shape (n_obs, n_vars) storing experimental measurements.
      
      ```python
      import anndata as ad
      import numpy as np
      
      # Create with dense array
      adata = ad.AnnData(X=np.random.rand(100, 2000))
      
      # Create with sparse matrix (recommended for large, sparse data)
      from scipy.sparse import csr_matrix
      sparse_data = csr_matrix(np.random.rand(100, 2000))
      adata = ad.AnnData(X=sparse_data)
      ```
      
      Access data:
      ```python
      # Full matrix (caution with large datasets)
      full_data = adata.X
      
      # Single observation
      obs_data = adata.X[0, :]
      
      # Single variable across all observations
      var_data = adata.X[:, 0]
      ```
      
      ### obs (Observation Annotations)
      DataFrame storing metadata about observations (rows). Each row corresponds to one observation in X.
      
      ```python
      import pandas as pd
      
      # Create AnnData with observation metadata
      obs_df = pd.DataFrame({
          'cell_type': ['T cell', 'B cell', 'Monocyte'],
          'treatment': ['control', 'treated', 'control'],
          'timepoint': [0, 24, 24]
      }, index=['cell_1', 'cell_2', 'cell_3'])
      
      adata = ad.AnnData(X=np.random.rand(3, 100), obs=obs_df)
      
      # Access observation metadata
      print(adata.obs['cell_type'])
      print(adata.obs.loc['cell_1'])
      ```
      
      ### var (Variable Annotations)
      DataFrame storing metadata about variables (columns). Each row corresponds to one variable in X.
      
      ```python
      # Create AnnData with variable metadata
      var_df = pd.DataFrame({
          'gene_name': ['ACTB', 'GAPDH', 'TP53'],
          'chromosome': ['7', '12', '17'],
          'highly_variable': [True, False, True]
      }, index=['ENSG00001', 'ENSG00002', 'ENSG00003'])
      
      adata = ad.AnnData(X=np.random.rand(100, 3), var=var_df)
      
      # Access variable metadata
      print(adata.var['gene_name'])
      print(adata.var.loc['ENSG00001'])
      ```
      
      ### layers (Alternative Data Representations)
      Dictionary storing alternative matrices with the same dimensions as X.
      
      ```python
      # Store raw counts, normalized data, and scaled data
      adata = ad.AnnData(X=np.random.rand(100, 2000))
      adata.layers['raw_counts'] = np.random.randint(0, 100, (100, 2000))
      adata.layers['normalized'] = adata.X / np.sum(adata.X, axis=1, keepdims=True)
      adata.layers['scaled'] = (adata.X - adata.X.mean()) / adata.X.std()
      
      # Access layers
      raw_data = adata.layers['raw_counts']
      normalized_data = adata.layers['normalized']
      ```
      
      Common layer uses:
      - `raw_counts`: Original count data before normalization
      - `normalized`: Log-normalized or TPM values
      - `scaled`: Z-scored values for analysis
      - `imputed`: Data after imputation
      
      ### obsm (Multi-dimensional Observation Annotations)
      Dictionary storing multi-dimensional arrays aligned to observations.
      
      ```python
      # Store PCA coordinates and UMAP embeddings
      adata.obsm['X_pca'] = np.random.rand(100, 50)  # 50 principal components
      adata.obsm['X_umap'] = np.random.rand(100, 2)  # 2D UMAP coordinates
      adata.obsm['X_tsne'] = np.random.rand(100, 2)  # 2D t-SNE coordinates
      
      # Access embeddings
      pca_coords = adata.obsm['X_pca']
      umap_coords = adata.obsm['X_umap']
      ```
      
      Common obsm uses:
      - `X_pca`: Principal component coordinates
      - `X_umap`: UMAP embedding coordinates
      - `X_tsne`: t-SNE embedding coordinates
      - `X_diffmap`: Diffusion map coordinates
      - `protein_expression`: Protein abundance measurements (CITE-seq)
      
      ### varm (Multi-dimensional Variable Annotations)
      Dictionary storing multi-dimensional arrays aligned to variables.
      
      ```python
      # Store PCA loadings
      adata.varm['PCs'] = np.random.rand(2000, 50)  # Loadings for 50 components
      adata.varm['gene_modules'] = np.random.rand(2000, 10)  # Gene module scores
      
      # Access loadings
      pc_loadings = adata.varm['PCs']
      ```
      
      Common varm uses:
      - `PCs`: Principal component loadings
      - `gene_modules`: Gene co-expression module assignments
      
      ### obsp (Pairwise Observation Relationships)
      Dictionary storing sparse matrices representing relationships between observations.
      
      ```python
      from scipy.sparse import csr_matrix
      
      # Store k-nearest neighbor graph
      n_obs = 100
      knn_graph = csr_matrix(np.random.rand(n_obs, n_obs) > 0.95)
      adata.obsp['connectivities'] = knn_graph
      adata.obsp['distances'] = csr_matrix(np.random.rand(n_obs, n_obs))
      
      # Access graphs
      knn_connections = adata.obsp['connectivities']
      distances = adata.obsp['distances']
      ```
      
      Common obsp uses:
      - `connectivities`: Cell-cell neighborhood graph
      - `distances`: Pairwise distances between cells
      
      ### varp (Pairwise Variable Relationships)
      Dictionary storing sparse matrices representing relationships between variables.
      
      ```python
      # Store gene-gene correlation matrix
      n_vars = 2000
      gene_corr = csr_matrix(np.random.rand(n_vars, n_vars) > 0.99)
      adata.varp['correlations'] = gene_corr
      
      # Access correlations
      gene_correlations = adata.varp['correlations']
      ```
      
      ### uns (Unstructured Annotations)
      Dictionary storing arbitrary unstructured metadata.
      
      ```python
      # Store analysis parameters and results
      adata.uns['experiment_date'] = '2025-11-03'
      adata.uns['pca'] = {
          'variance_ratio': [0.15, 0.10, 0.08],
          'params': {'n_comps': 50}
      }
      adata.uns['neighbors'] = {
          'params': {'n_neighbors': 15, 'method': 'umap'},
          'connectivities_key': 'connectivities'
      }
      
      # Access unstructured data
      exp_date = adata.uns['experiment_date']
      pca_params = adata.uns['pca']['params']
      ```
      
      Common uns uses:
      - Analysis parameters and settings
      - Color palettes for plotting
      - Cluster information
      - Tool-specific metadata
      
      ### raw (Original Data Snapshot)
      Optional attribute preserving the original data matrix and variable annotations before filtering.
      
      ```python
      # Create AnnData and store raw state
      adata = ad.AnnData(X=np.random.rand(100, 5000))
      adata.var['gene_name'] = [f'Gene_{i}' for i in range(5000)]
      
      # Store raw state before filtering
      adata.raw = adata.copy()
      
      # Filter to highly variable genes
      highly_variable_mask = np.random.rand(5000) > 0.5
      adata = adata[:, highly_variable_mask]
      
      # Access original data
      original_matrix = adata.raw.X
      original_var = adata.raw.var
      ```
      
      ## Object Properties
      
      ```python
      # Dimensions
      n_observations = adata.n_obs
      n_variables = adata.n_vars
      shape = adata.shape  # (n_obs, n_vars)
      
      # Index information
      obs_names = adata.obs_names  # Observation identifiers
      var_names = adata.var_names  # Variable identifiers
      
      # Storage mode
      is_view = adata.is_view  # True if this is a view of another object
      is_backed = adata.isbacked  # True if backed by on-disk storage
      filename = adata.filename  # Path to backing file (if backed)
      ```
      
      ## Creating AnnData Objects
      
      ### From arrays and DataFrames
      ```python
      import anndata as ad
      import numpy as np
      import pandas as pd
      
      # Minimal creation
      X = np.random.rand(100, 2000)
      adata = ad.AnnData(X)
      
      # With metadata
      obs = pd.DataFrame({'cell_type': ['A', 'B'] * 50}, index=[f'cell_{i}' for i in range(100)])
      var = pd.DataFrame({'gene_name': [f'Gene_{i}' for i in range(2000)]}, index=[f'ENSG{i:05d}' for i in range(2000)])
      adata = ad.AnnData(X=X, obs=obs, var=var)
      
      # With all components
      adata = ad.AnnData(
          X=X,
          obs=obs,
          var=var,
          layers={'raw': np.random.randint(0, 100, (100, 2000))},
          obsm={'X_pca': np.random.rand(100, 50)},
          uns={'experiment': 'test'}
      )
      ```
      
      ### From DataFrame
      ```python
      # Create from pandas DataFrame (genes as columns, cells as rows)
      df = pd.DataFrame(
          np.random.rand(100, 50),
          columns=[f'Gene_{i}' for i in range(50)],
          index=[f'Cell_{i}' for i in range(100)]
      )
      adata = ad.AnnData(df)
      ```
      
      ## Data Access Patterns
      
      ### Vector extraction
      ```python
      # Get observation annotation as array
      cell_types = adata.obs_vector('cell_type')
      
      # Get variable values across observations
      gene_expression = adata.obs_vector('ACTB')  # If ACTB is in var_names
      
      # Get variable annotation as array
      gene_names = adata.var_vector('gene_name')
      ```
      
      ### Subsetting
      ```python
      # By index
      subset = adata[0:10, 0:100]  # First 10 obs, first 100 vars
      
      # By name
      subset = adata[['cell_1', 'cell_2'], ['ACTB', 'GAPDH']]
      
      # By boolean mask
      high_count_cells = adata.obs['total_counts'] > 1000
      subset = adata[high_count_cells, :]
      
      # By observation metadata
      t_cells = adata[adata.obs['cell_type'] == 'T cell']
      ```
      
      ## Memory Considerations
      
      The AnnData structure is designed for memory efficiency:
      - Sparse matrices reduce memory for sparse data
      - Views avoid copying data when possible
      - Backed mode enables working with data larger than RAM
      - Categorical annotations reduce memory for discrete values
      
      ```python
      # Convert strings to categoricals (more memory efficient)
      adata.obs['cell_type'] = adata.obs['cell_type'].astype('category')
      adata.strings_to_categoricals()
      
      # Check if object is a view (doesn't own data)
      if adata.is_view:
          adata = adata.copy()  # Create independent copy
      ```
      
    • io_operations.md 10.5 KB
      # Input/Output Operations
      
      AnnData provides comprehensive I/O functionality for reading and writing data in various formats.
      
      > **Namespaces (anndata >= 0.11):** all format readers/writers live in the
      > `anndata.io` module — use `ad.io.read_csv`, `ad.io.read_mtx`, `ad.io.read_loom`,
      > `ad.io.read_elem`, etc. The old top-level `ad.read_csv`-style names still work
      > but warn. **Exceptions that stay top-level without a warning:** `ad.read_h5ad`,
      > `ad.read_zarr`, `adata.write_h5ad`, `adata.write_zarr`.
      
      ## Native Formats
      
      ### H5AD (HDF5-based)
      The recommended native format for AnnData objects, providing efficient storage and fast access.
      
      #### Writing H5AD files
      ```python
      import anndata as ad
      
      # Write to file
      adata.write_h5ad('data.h5ad')
      
      # Write with compression
      adata.write_h5ad('data.h5ad', compression='gzip')
      
      # Write with specific compression level (0-9, higher = more compression)
      adata.write_h5ad('data.h5ad', compression='gzip', compression_opts=9)
      ```
      
      #### Reading H5AD files
      ```python
      # Read entire file into memory
      adata = ad.read_h5ad('data.h5ad')
      
      # Read in backed mode (lazy loading for large files)
      adata = ad.read_h5ad('data.h5ad', backed='r')  # Read-only
      adata = ad.read_h5ad('data.h5ad', backed='r+')  # Read-write
      
      # Backed mode enables working with datasets larger than RAM
      # Only accessed data is loaded into memory
      ```
      
      #### Backed mode operations
      ```python
      # Open in backed mode
      adata = ad.read_h5ad('large_dataset.h5ad', backed='r')
      
      # Access metadata without loading X into memory
      print(adata.obs.head())
      print(adata.var.head())
      
      # Subset operations create views
      subset = adata[:100, :500]  # View, no data loaded
      
      # Load specific data into memory
      X_subset = subset.X[:]  # Now loads this subset
      
      # Convert entire backed object to memory
      adata_memory = adata.to_memory()
      ```
      
      ### Zarr
      Hierarchical array storage format, optimized for cloud storage and parallel I/O.
      
      #### Writing Zarr
      ```python
      # Write to Zarr store
      adata.write_zarr('data.zarr')
      
      # Write with specific chunks (important for performance)
      adata.write_zarr('data.zarr', chunks=(100, 100))
      ```
      
      #### Reading Zarr
      ```python
      # Read Zarr store
      adata = ad.read_zarr('data.zarr')
      ```
      
      #### Remote Zarr access
      ```python
      import fsspec
      
      # Access Zarr from S3
      store = fsspec.get_mapper('s3://bucket-name/data.zarr')
      adata = ad.read_zarr(store)
      
      # Access Zarr from URL
      store = fsspec.get_mapper('https://example.com/data.zarr')
      adata = ad.read_zarr(store)
      ```
      
      ## Alternative Input Formats
      
      ### CSV/TSV
      ```python
      # Read CSV (genes as columns, cells as rows)
      adata = ad.io.read_csv('data.csv')
      
      # Read with custom delimiter
      adata = ad.io.read_csv('data.tsv', delimiter='\t')
      
      # Specify that first column is row names
      adata = ad.io.read_csv('data.csv', first_column_names=True)
      ```
      
      ### Excel
      ```python
      # Read Excel file
      adata = ad.io.read_excel('data.xlsx')
      
      # Read specific sheet
      adata = ad.io.read_excel('data.xlsx', sheet='Sheet1')
      ```
      
      ### Matrix Market (MTX)
      Common format for sparse matrices in genomics. `.mtx` stores variables x
      observations; `read_mtx` does NOT transpose for you, so verify orientation.
      
      ```python
      # Read MTX (just the matrix)
      adata = ad.io.read_mtx('matrix.mtx')
      
      # MTX often has genes as rows — transpose so cells are observations
      adata = adata.T
      ```
      
      ### 10X Genomics formats
      The 10x readers (`read_10x_h5`, `read_10x_mtx`) live in **scanpy**, not anndata. They return a standard AnnData object.
      ```python
      import scanpy as sc
      
      # Read 10X h5 format
      adata = sc.read_10x_h5('filtered_feature_bc_matrix.h5')
      
      # Read 10X MTX directory
      adata = sc.read_10x_mtx('filtered_feature_bc_matrix/')
      
      # Specify genome if multiple present
      adata = sc.read_10x_h5('data.h5', genome='GRCh38')
      ```
      
      ### Loom (deprecated since anndata 0.13)
      
      Loom reading and writing emit a deprecation warning in 0.13 — the format is
      lightly maintained and covers only part of the AnnData model. Convert to h5ad or
      zarr once and work from there.
      
      ```python
      # Read Loom file
      adata = ad.io.read_loom('data.loom')
      
      # Read with specific observation and variable annotations
      adata = ad.io.read_loom(
          'data.loom',
          obs_names='CellID',
          var_names='Gene'
      )
      ```
      
      ### Text files
      ```python
      # Read generic text file
      adata = ad.io.read_text('data.txt', delimiter='\t')
      
      # Read with custom parameters
      adata = ad.io.read_text(
          'data.txt',
          delimiter=',',
          first_column_names=True,
          dtype='float32'
      )
      ```
      
      ### UMI tools
      ```python
      # Read UMI tools format
      adata = ad.io.read_umi_tools('counts.tsv')
      ```
      
      ### HDF5 (generic)
      ```python
      # Read from HDF5 file (not h5ad format)
      adata = ad.io.read_hdf('data.h5', key='dataset')
      ```
      
      ## Alternative Output Formats
      
      ### CSV
      ```python
      # Write annotation to CSV files (one file per component).
      # skip_data defaults to True, so X is NOT written unless you ask for it.
      adata.write_csvs('output_dir/')                    # obs.csv, var.csv, uns/... only
      adata.write_csvs('output_dir/', skip_data=False)   # also writes X.csv
      
      # A CSV export cannot round-trip an AnnData — use write_h5ad for that.
      ```
      
      ### Loom
      ```python
      # Write to Loom format
      adata.write_loom('output.loom')
      ```
      
      ## Reading/Writing Specific Elements
      
      For fine-grained control, read or write individual elements (an obs DataFrame, a
      sparse matrix, a layer) without constructing a full AnnData. The element API
      takes an **open** h5py/zarr group and an in-group path — not a filesystem path
      string. Lives under `ad.io`.
      
      ```python
      import anndata as ad
      import h5py
      
      # Read just observation annotations (pass the open group element)
      with h5py.File('data.h5ad', 'r') as f:
          obs = ad.io.read_elem(f['obs'])
          layer = ad.io.read_elem(f['layers/normalized'])
      
      # Write an element into an existing file
      with h5py.File('data.h5ad', 'a') as f:
          ad.io.write_elem(f, 'layers/new_layer', adata.X.copy())
      ```
      
      ## Lazy Operations
      
      For very large datasets, open the whole store lazily so array data is backed by
      Dask and annotation frames by xarray — nothing is read until accessed. Pass an
      open h5py file (you manage its lifetime):
      
      ```python
      import anndata as ad
      import h5py
      
      f = h5py.File('large_data.h5ad', 'r')
      adata = ad.experimental.read_lazy(f)
      
      print(type(adata.X))   # dask.array.core.Array
      # Compute only the slice you need
      subset = adata.X[:100, :100].compute()
      ```
      
      ## Common I/O Patterns
      
      ### Convert between formats
      ```python
      # MTX to H5AD
      adata = ad.io.read_mtx('matrix.mtx').T
      adata.write_h5ad('data.h5ad')
      
      # CSV to H5AD
      adata = ad.io.read_csv('data.csv')
      adata.write_h5ad('data.h5ad')
      
      # H5AD to Zarr
      adata = ad.read_h5ad('data.h5ad')
      adata.write_zarr('data.zarr')
      ```
      
      ### Load metadata without data
      ```python
      # Backed mode allows inspecting metadata without loading X
      adata = ad.read_h5ad('large_file.h5ad', backed='r')
      print(f"Dataset contains {adata.n_obs} observations and {adata.n_vars} variables")
      print(adata.obs.columns)
      print(adata.var.columns)
      # X is not loaded into memory
      ```
      
      ### Append to existing file
      ```python
      # Open in read-write mode
      adata = ad.read_h5ad('data.h5ad', backed='r+')
      
      # Modify metadata
      adata.obs['new_column'] = values
      
      # Changes are written to disk
      ```
      
      ### Download from URL
      ```python
      import anndata as ad
      
      # Read directly from URL (for h5ad files)
      url = 'https://example.com/data.h5ad'
      adata = ad.read_h5ad(url, backed='r')  # Streaming access
      
      # For other formats, download first
      import urllib.request
      urllib.request.urlretrieve(url, 'local_file.h5ad')
      adata = ad.read_h5ad('local_file.h5ad')
      ```
      
      ## Performance Tips
      
      ### Reading
      - Use `backed='r'` for large files you only need to query
      - Use `backed='r+'` if you need to modify metadata without loading all data
      - H5AD format is generally fastest for random access
      - Zarr is better for cloud storage and parallel access
      - Consider compression for storage, but note it may slow down reading
      
      ### Writing
      - Use compression for long-term storage: `compression='gzip'` or `compression='lzf'`
      - LZF compression is faster but compresses less than GZIP
      - For Zarr, tune chunk sizes based on access patterns:
        - Larger chunks for sequential reads
        - Smaller chunks for random access
      - Convert string columns to categorical before writing (smaller files)
      
      ### Memory management
      ```python
      # Convert strings to categoricals (reduces file size and memory)
      adata.strings_to_categoricals()
      adata.write_h5ad('data.h5ad')
      
      # Use sparse matrices for sparse data
      from scipy.sparse import csr_matrix
      if isinstance(adata.X, np.ndarray):
          density = np.count_nonzero(adata.X) / adata.X.size
          if density < 0.5:  # If more than 50% zeros
              adata.X = csr_matrix(adata.X)
      ```
      
      ## Handling Large Datasets
      
      ### Strategy 1: Backed mode
      ```python
      # Work with dataset larger than RAM
      adata = ad.read_h5ad('100GB_file.h5ad', backed='r')
      
      # Filter based on metadata (fast, no data loading)
      filtered = adata[adata.obs['quality_score'] > 0.8]
      
      # Load filtered subset into memory
      adata_memory = filtered.to_memory()
      ```
      
      ### Strategy 2: Chunked processing
      ```python
      # Process data in chunks
      adata = ad.read_h5ad('large_file.h5ad', backed='r')
      
      chunk_size = 1000
      results = []
      
      for i in range(0, adata.n_obs, chunk_size):
          chunk = adata[i:i+chunk_size, :].to_memory()
          # Process chunk
          result = process(chunk)
          results.append(result)
      ```
      
      ### Strategy 3: Use AnnCollection
      A lightweight virtual collection over several AnnData objects that behaves like
      one large dataset without concatenating in memory. Pass backed AnnData objects
      (read each with `backed='r'`) so X stays on disk until a batch is accessed.
      ```python
      import anndata as ad
      from anndata.experimental import AnnCollection
      
      # Open each file backed, then wrap — data is loaded only when a batch is sliced
      adatas = [ad.read_h5ad(f'dataset_{i}.h5ad', backed='r') for i in range(10)]
      collection = AnnCollection(adatas)
      
      batch = collection[10:20]   # materializes just this slice
      print(batch.X.shape)
      ```
      
      ## Common Issues and Solutions
      
      ### Issue: Out of memory when reading
      **Solution**: Use backed mode or read in chunks
      ```python
      adata = ad.read_h5ad('file.h5ad', backed='r')
      ```
      
      ### Issue: Slow reading from cloud storage
      **Solution**: Use Zarr format with appropriate chunking
      ```python
      adata.write_zarr('data.zarr', chunks=(1000, 1000))
      ```
      
      ### Issue: Large file sizes
      **Solution**: Use compression and convert to sparse/categorical
      ```python
      adata.strings_to_categoricals()
      from scipy.sparse import csr_matrix
      adata.X = csr_matrix(adata.X)
      adata.write_h5ad('compressed.h5ad', compression='gzip')
      ```
      
      ### Issue: Cannot modify backed object
      **Solution**: Either load to memory or open in 'r+' mode
      ```python
      # Option 1: Load to memory
      adata = adata.to_memory()
      
      # Option 2: Open in read-write mode
      adata = ad.read_h5ad('file.h5ad', backed='r+')
      ```
      
    • manipulation.md 11.7 KB
      # Data Manipulation
      
      Operations for transforming, subsetting, and manipulating AnnData objects.
      
      ## Subsetting
      
      ### By indices
      ```python
      import anndata as ad
      import numpy as np
      
      adata = ad.AnnData(X=np.random.rand(1000, 2000))
      
      # Integer indices
      subset = adata[0:100, 0:500]  # First 100 obs, first 500 vars
      
      # List of indices
      obs_indices = [0, 10, 20, 30, 40]
      var_indices = [0, 1, 2, 3, 4]
      subset = adata[obs_indices, var_indices]
      
      # Single observation or variable
      single_obs = adata[0, :]
      single_var = adata[:, 0]
      ```
      
      ### By names
      ```python
      import pandas as pd
      
      # Create with named indices
      obs_names = [f'cell_{i}' for i in range(1000)]
      var_names = [f'gene_{i}' for i in range(2000)]
      adata = ad.AnnData(
          X=np.random.rand(1000, 2000),
          obs=pd.DataFrame(index=obs_names),
          var=pd.DataFrame(index=var_names)
      )
      
      # Subset by observation names
      subset = adata[['cell_0', 'cell_1', 'cell_2'], :]
      
      # Subset by variable names
      subset = adata[:, ['gene_0', 'gene_10', 'gene_20']]
      
      # Both axes
      subset = adata[['cell_0', 'cell_1'], ['gene_0', 'gene_1']]
      ```
      
      ### By boolean masks
      ```python
      # Create boolean masks
      high_count_obs = np.random.rand(1000) > 0.5
      high_var_genes = np.random.rand(2000) > 0.7
      
      # Subset using masks
      subset = adata[high_count_obs, :]
      subset = adata[:, high_var_genes]
      subset = adata[high_count_obs, high_var_genes]
      ```
      
      ### By metadata conditions
      ```python
      # Add metadata
      adata.obs['cell_type'] = np.random.choice(['A', 'B', 'C'], 1000)
      adata.obs['quality_score'] = np.random.rand(1000)
      adata.var['highly_variable'] = np.random.rand(2000) > 0.8
      
      # Filter by cell type
      t_cells = adata[adata.obs['cell_type'] == 'A']
      
      # Filter by multiple conditions
      high_quality_a_cells = adata[
          (adata.obs['cell_type'] == 'A') &
          (adata.obs['quality_score'] > 0.7)
      ]
      
      # Filter by variable metadata
      hv_genes = adata[:, adata.var['highly_variable']]
      
      # Complex conditions
      filtered = adata[
          (adata.obs['quality_score'] > 0.5) &
          (adata.obs['cell_type'].isin(['A', 'B'])),
          adata.var['highly_variable']
      ]
      ```
      
      ## Transposition
      
      ```python
      # Transpose AnnData object (swap observations and variables)
      adata_T = adata.T
      
      # Shape changes
      print(adata.shape)    # (1000, 2000)
      print(adata_T.shape)  # (2000, 1000)
      
      # obs and var are swapped
      print(adata.obs.head())   # Observation metadata
      print(adata_T.var.head()) # Same data, now as variable metadata
      
      # Useful when data is in opposite orientation
      # Common with some file formats where genes are rows
      ```
      
      ## Copying
      
      ### Full copy
      ```python
      # Create independent copy
      adata_copy = adata.copy()
      
      # Modifications to copy don't affect original
      adata_copy.obs['new_column'] = 1
      print('new_column' in adata.obs.columns)  # False
      ```
      
      ### Shallow copy
      ```python
      # View (doesn't copy data, modifications affect original)
      adata_view = adata[0:100, :]
      
      # Check if object is a view
      print(adata_view.is_view)  # True
      
      # Convert view to independent copy
      adata_independent = adata_view.copy()
      print(adata_independent.is_view)  # False
      ```
      
      ## Renaming
      
      ### Rename observations and variables
      ```python
      # Rename all observations
      adata.obs_names = [f'new_cell_{i}' for i in range(adata.n_obs)]
      
      # Rename all variables
      adata.var_names = [f'new_gene_{i}' for i in range(adata.n_vars)]
      
      # Make names unique (add suffix to duplicates)
      adata.obs_names_make_unique()
      adata.var_names_make_unique()
      ```
      
      ### Rename categories
      ```python
      # Create categorical column
      adata.obs['cell_type'] = pd.Categorical(['A', 'B', 'C'] * 333 + ['A'])
      
      # Rename categories
      adata.rename_categories('cell_type', ['Type_A', 'Type_B', 'Type_C'])
      
      # Or using dictionary
      adata.rename_categories('cell_type', {
          'Type_A': 'T_cell',
          'Type_B': 'B_cell',
          'Type_C': 'Monocyte'
      })
      ```
      
      ## Type Conversions
      
      ### Strings to categoricals
      ```python
      # Convert string columns to categorical (more memory efficient)
      adata.obs['cell_type'] = ['TypeA', 'TypeB'] * 500
      adata.obs['tissue'] = ['brain', 'liver'] * 500
      
      # Convert all string columns to categorical
      adata.strings_to_categoricals()
      
      print(adata.obs['cell_type'].dtype)  # category
      print(adata.obs['tissue'].dtype)     # category
      ```
      
      ### Sparse to dense and vice versa
      ```python
      from scipy.sparse import csr_matrix
      
      # Dense to sparse
      if not isinstance(adata.X, csr_matrix):
          adata.X = csr_matrix(adata.X)
      
      # Sparse to dense
      if isinstance(adata.X, csr_matrix):
          adata.X = adata.X.toarray()
      
      # Convert layer
      adata.layers['normalized'] = csr_matrix(adata.layers['normalized'])
      ```
      
      ## Chunked Operations
      
      Process large datasets in chunks:
      
      ```python
      # Iterate through data in chunks
      chunk_size = 100
      for chunk in adata.chunked_X(chunk_size):
          # Process chunk
          result = process_chunk(chunk)
      ```
      
      ## Extracting Vectors
      
      ### Get observation vectors
      ```python
      # Get observation metadata as array
      cell_types = adata.obs_vector('cell_type')
      
      # Get gene expression across observations
      actb_expression = adata.obs_vector('ACTB')  # If ACTB in var_names
      ```
      
      ### Get variable vectors
      ```python
      # Get variable metadata as array
      gene_names = adata.var_vector('gene_name')
      ```
      
      ## Adding/Modifying Data
      
      ### Add observations
      ```python
      # Create new observations
      new_obs = ad.AnnData(X=np.random.rand(100, adata.n_vars))
      new_obs.var_names = adata.var_names
      
      # Concatenate with existing
      adata_extended = ad.concat([adata, new_obs], axis=0)
      ```
      
      ### Add variables
      ```python
      # Create new variables
      new_vars = ad.AnnData(X=np.random.rand(adata.n_obs, 100))
      new_vars.obs_names = adata.obs_names
      
      # Concatenate with existing
      adata_extended = ad.concat([adata, new_vars], axis=1)
      ```
      
      ### Add metadata columns
      ```python
      # Add observation annotation
      adata.obs['new_score'] = np.random.rand(adata.n_obs)
      
      # Add variable annotation
      adata.var['new_label'] = ['label'] * adata.n_vars
      
      # Add from external data
      external_data = pd.read_csv('metadata.csv', index_col=0)
      adata.obs['external_info'] = external_data.loc[adata.obs_names, 'column']
      ```
      
      ### Add layers
      ```python
      # Add new layer
      adata.layers['raw_counts'] = np.random.randint(0, 100, adata.shape)
      adata.layers['log_transformed'] = np.log1p(adata.X)
      
      # Replace layer
      adata.layers['normalized'] = new_normalized_data
      ```
      
      ### Add embeddings
      ```python
      # Add PCA
      adata.obsm['X_pca'] = np.random.rand(adata.n_obs, 50)
      
      # Add UMAP
      adata.obsm['X_umap'] = np.random.rand(adata.n_obs, 2)
      
      # Add multiple embeddings
      adata.obsm['X_tsne'] = np.random.rand(adata.n_obs, 2)
      adata.obsm['X_diffmap'] = np.random.rand(adata.n_obs, 10)
      ```
      
      ### Add pairwise relationships
      ```python
      from scipy.sparse import csr_matrix
      
      # Add nearest neighbor graph
      n_obs = adata.n_obs
      knn_graph = csr_matrix(np.random.rand(n_obs, n_obs) > 0.95)
      adata.obsp['connectivities'] = knn_graph
      
      # Add distance matrix
      adata.obsp['distances'] = csr_matrix(np.random.rand(n_obs, n_obs))
      ```
      
      ### Add unstructured data
      ```python
      # Add analysis parameters
      adata.uns['pca'] = {
          'variance': [0.2, 0.15, 0.1],
          'variance_ratio': [0.4, 0.3, 0.2],
          'params': {'n_comps': 50}
      }
      
      # Add color schemes
      adata.uns['cell_type_colors'] = ['#FF0000', '#00FF00', '#0000FF']
      ```
      
      ## Removing Data
      
      ### Remove observations or variables
      ```python
      # Keep only specific observations
      keep_obs = adata.obs['quality_score'] > 0.5
      adata = adata[keep_obs, :]
      
      # Remove specific variables
      remove_vars = adata.var['low_count']
      adata = adata[:, ~remove_vars]
      ```
      
      ### Remove metadata columns
      ```python
      # Remove observation column
      adata.obs.drop('unwanted_column', axis=1, inplace=True)
      
      # Remove variable column
      adata.var.drop('unwanted_column', axis=1, inplace=True)
      ```
      
      ### Remove layers
      ```python
      # Remove specific layer
      del adata.layers['unwanted_layer']
      
      # Remove all layers
      adata.layers = {}
      ```
      
      ### Remove embeddings
      ```python
      # Remove specific embedding
      del adata.obsm['X_tsne']
      
      # Remove all embeddings
      adata.obsm = {}
      ```
      
      ### Remove unstructured data
      ```python
      # Remove specific key
      del adata.uns['unwanted_key']
      
      # Remove all unstructured data
      adata.uns = {}
      ```
      
      ## Reordering
      
      ### Sort observations
      ```python
      # Sort by observation metadata
      adata = adata[adata.obs.sort_values('quality_score').index, :]
      
      # Sort by observation names
      adata = adata[sorted(adata.obs_names), :]
      ```
      
      ### Sort variables
      ```python
      # Sort by variable metadata
      adata = adata[:, adata.var.sort_values('gene_name').index]
      
      # Sort by variable names
      adata = adata[:, sorted(adata.var_names)]
      ```
      
      ### Reorder to match external list
      ```python
      # Reorder observations to match external list
      desired_order = ['cell_10', 'cell_5', 'cell_20', ...]
      adata = adata[desired_order, :]
      
      # Reorder variables
      desired_genes = ['TP53', 'ACTB', 'GAPDH', ...]
      adata = adata[:, desired_genes]
      ```
      
      ## Data Transformations
      
      ### Normalize
      ```python
      # Total count normalization (CPM/TPM-like)
      total_counts = adata.X.sum(axis=1)
      adata.layers['normalized'] = adata.X / total_counts[:, np.newaxis] * 1e6
      
      # Log transformation
      adata.layers['log1p'] = np.log1p(adata.X)
      
      # Z-score normalization
      mean = adata.X.mean(axis=0)
      std = adata.X.std(axis=0)
      adata.layers['scaled'] = (adata.X - mean) / std
      ```
      
      ### Filter
      ```python
      # Filter cells by total counts
      total_counts = np.array(adata.X.sum(axis=1)).flatten()
      adata.obs['total_counts'] = total_counts
      adata = adata[adata.obs['total_counts'] > 1000, :]
      
      # Filter genes by detection rate
      detection_rate = (adata.X > 0).sum(axis=0) / adata.n_obs
      adata.var['detection_rate'] = np.array(detection_rate).flatten()
      adata = adata[:, adata.var['detection_rate'] > 0.01]
      ```
      
      ## Working with Views
      
      Views are lightweight references to subsets of data that don't copy the underlying matrix:
      
      ```python
      # Create view
      view = adata[0:100, 0:500]
      print(view.is_view)  # True
      
      # Views allow read access
      data = view.X
      
      # Writing into a view is copy-on-write in anndata >= 0.13: the view
      # materializes its own copy and the parent object is left unchanged.
      
      # Convert view to independent copy
      independent = view.copy()
      
      # Force AnnData to be a copy, not a view
      adata = adata.copy()
      ```
      
      ## Merging Metadata
      
      ```python
      # Merge external metadata
      external_metadata = pd.read_csv('additional_metadata.csv', index_col=0)
      
      # Join metadata (inner join on index)
      adata.obs = adata.obs.join(external_metadata)
      
      # Left join (keep all adata observations)
      adata.obs = adata.obs.merge(
          external_metadata,
          left_index=True,
          right_index=True,
          how='left'
      )
      ```
      
      ## Common Manipulation Patterns
      
      ### Quality control filtering
      ```python
      # Calculate QC metrics
      adata.obs['n_genes'] = (adata.X > 0).sum(axis=1)
      adata.obs['total_counts'] = adata.X.sum(axis=1)
      adata.var['n_cells'] = (adata.X > 0).sum(axis=0)
      
      # Filter low-quality cells
      adata = adata[adata.obs['n_genes'] > 200, :]
      adata = adata[adata.obs['total_counts'] < 50000, :]
      
      # Filter rarely detected genes
      adata = adata[:, adata.var['n_cells'] >= 3]
      ```
      
      ### Select highly variable genes
      ```python
      # Mark highly variable genes
      gene_variance = np.var(adata.X, axis=0)
      adata.var['variance'] = np.array(gene_variance).flatten()
      adata.var['highly_variable'] = adata.var['variance'] > np.percentile(gene_variance, 90)
      
      # Subset to highly variable genes
      adata_hvg = adata[:, adata.var['highly_variable']].copy()
      ```
      
      ### Downsample
      ```python
      # Random sampling of observations
      np.random.seed(42)
      n_sample = 500
      sample_indices = np.random.choice(adata.n_obs, n_sample, replace=False)
      adata_downsampled = adata[sample_indices, :].copy()
      
      # Stratified sampling by cell type
      from sklearn.model_selection import train_test_split
      train_idx, test_idx = train_test_split(
          range(adata.n_obs),
          test_size=0.2,
          stratify=adata.obs['cell_type']
      )
      adata_train = adata[train_idx, :].copy()
      adata_test = adata[test_idx, :].copy()
      ```
      
      ### Split train/test
      ```python
      # Random train/test split
      np.random.seed(42)
      n_obs = adata.n_obs
      train_size = int(0.8 * n_obs)
      indices = np.random.permutation(n_obs)
      train_indices = indices[:train_size]
      test_indices = indices[train_size:]
      
      adata_train = adata[train_indices, :].copy()
      adata_test = adata[test_indices, :].copy()
      ```
      
  • SKILL.md 13.8 KB
    ---
    name: alterlab-anndata
    description: Build, slice, concatenate, read, and write AnnData annotated data matrices (obs, var, X, layers, obsm, uns) — the scverse data STRUCTURE, not an analysis pipeline. Use when creating or wrangling .h5ad/zarr files, managing cell and gene annotations, concatenating batches, or handling layers/obsm/backed-mode; for the QC, normalization, clustering, UMAP, and differential-expression analysis pipeline prefer alterlab-scanpy instead, and for RNA velocity from spliced/unspliced layers prefer alterlab-scvelo instead. Part of the AlterLab Academic Skills suite.
    license: MIT
    allowed-tools: Read Write Edit Bash(python:*) Bash(uv:*)
    compatibility: "Runs under `uv run python` with `anndata` >= 0.13 (current 0.13.4 as of 2026-09, requires Python >= 3.12, zarr >= 3.1) installed in the project env; no API key or account required. The 0.11/0.12 APIs still work except where flagged below."
    metadata:
        skill-author: AlterLab
        version: "1.1.0"
        last_updated: "2026-09-23"
    ---
    
    # AnnData
    
    ## Overview
    
    AnnData is a Python package for handling annotated data matrices, storing experimental measurements (X) alongside observation metadata (obs), variable metadata (var), and multi-dimensional annotations (obsm, varm, obsp, varp, uns). Originally designed for single-cell genomics through Scanpy, it now serves as a general-purpose framework for any annotated data requiring efficient storage, manipulation, and analysis.
    
    ## When to Use This Skill
    
    Use this skill when:
    - Creating, reading, or writing AnnData objects
    - Working with h5ad, zarr, or other genomics data formats
    - Performing single-cell RNA-seq analysis
    - Managing large datasets with sparse matrices or backed mode
    - Concatenating multiple datasets or experimental batches
    - Subsetting, filtering, or transforming annotated data
    - Integrating with scanpy, scvi-tools, or other scverse ecosystem tools
    
    ### Does NOT Trigger
    
    | Scenario | Use Instead |
    |----------|-------------|
    | QC, normalization, HVG, clustering, UMAP, or marker genes | `alterlab-scanpy` |
    | RNA velocity from spliced/unspliced layers | `alterlab-scvelo` |
    | Deep generative models / batch integration (scVI, scANVI) | `alterlab-scvi-tools` |
    | Querying the public CELLxGENE Census for reference cells | `alterlab-cellxgene` |
    | Chunked n-dimensional arrays outside the AnnData model | `alterlab-zarr` |
    
    ## Installation
    
    ```bash
    uv pip install anndata          # 0.13.x as of 2026-09; needs Python >= 3.12
    
    # Lazy/backed reads (ad.experimental.read_lazy) need dask + xarray:
    uv pip install 'anndata[lazy]'   # or 'anndata[dask]' for dask-backed arrays only
    ```
    
    ### What changed in 0.13 (breaking)
    
    These bite existing scripts, so check them before running older code:
    
    - **`AnnData.concatenate()` is gone** — use the module-level `ad.concat([...])`.
    - **`.X` is copy-on-write** like `layers`/`obsm`: writing into a subset (`view.X = 0`) no
      longer propagates to the parent object. `.X` is now stored as `layers[None]`, so it shows
      up in the repr and in `.layers.keys()`.
    - **The `dtype=` argument to the `AnnData` constructor was removed** — cast `X` yourself.
    - **zarr v3 only** (`zarr >= 3.1`); writing the v2 format is deprecated as of 0.13.4 and
      goes away in 0.14. Stores are written sharded by default.
    - **`anndata.__version__` and the `adata.*_keys()` methods are deprecated** — use
      `importlib.metadata.version("anndata")` and `sorted(adata.obs)` / `k in adata.uns`.
    - **Loom I/O is deprecated** (`ad.io.read_loom`, `adata.write_loom`) in favour of h5ad/zarr.
    
    ## Quick Start
    
    ### Creating an AnnData object
    ```python
    import anndata as ad
    import numpy as np
    import pandas as pd
    
    # Minimal creation
    X = np.random.rand(100, 2000)  # 100 cells × 2000 genes
    adata = ad.AnnData(X)
    
    # With metadata
    obs = pd.DataFrame({
        'cell_type': ['T cell', 'B cell'] * 50,
        'sample': ['A', 'B'] * 50
    }, index=[f'cell_{i}' for i in range(100)])
    
    var = pd.DataFrame({
        'gene_name': [f'Gene_{i}' for i in range(2000)]
    }, index=[f'ENSG{i:05d}' for i in range(2000)])
    
    adata = ad.AnnData(X=X, obs=obs, var=var)
    ```
    
    ### Reading data
    ```python
    import scanpy as sc  # 10x readers live in scanpy, not anndata
    
    # Read h5ad file
    adata = ad.read_h5ad('data.h5ad')
    
    # Read with backed mode (for large files)
    adata = ad.read_h5ad('large_data.h5ad', backed='r')
    
    # Read other formats (these live under ad.io as of anndata 0.11)
    adata = ad.io.read_csv('data.csv')
    adata = ad.io.read_mtx('matrix.mtx')
    adata = sc.read_10x_h5('filtered_feature_bc_matrix.h5')
    ```
    
    > **API namespaces (anndata >= 0.11)**: all format readers/writers live in the
    > `anndata.io` module (`ad.io.read_csv`, `ad.io.read_mtx`, `ad.io.read_loom`,
    > `ad.io.read_elem`, ...). The top-level `ad.read_csv`-style aliases still resolve
    > but emit a `FutureWarning` (upgraded from `DeprecationWarning` in 0.12), and
    > `anndata.read` was removed in 0.12. **Exceptions**: `ad.read_h5ad`, `ad.read_zarr`,
    > `adata.write_h5ad`, and `adata.write_zarr` stay top-level with no warning.
    >
    > **10x readers** (`read_10x_h5`, `read_10x_mtx`) live in **scanpy**
    > (`sc.read_10x_h5`), not anndata — this skill defers analysis-specific I/O to scanpy.
    
    ### Writing data
    ```python
    # Write h5ad file
    adata.write_h5ad('output.h5ad')
    
    # Write with compression
    adata.write_h5ad('output.h5ad', compression='gzip')
    
    # Write other formats
    adata.write_zarr('output.zarr')                  # zarr v3, sharded by default
    adata.write_csvs('output_dir/', skip_data=False)  # skip_data defaults to True (obs/var only)
    ```
    
    ### Basic operations
    ```python
    # Subset by conditions
    t_cells = adata[adata.obs['cell_type'] == 'T cell']
    
    # Subset by indices
    subset = adata[0:50, 0:100]
    
    # Add metadata
    adata.obs['quality_score'] = np.random.rand(adata.n_obs)
    adata.var['highly_variable'] = np.random.rand(adata.n_vars) > 0.8
    
    # Access dimensions
    print(f"{adata.n_obs} observations × {adata.n_vars} variables")
    ```
    
    ## Core Capabilities
    
    ### 1. Data Structure
    
    Understand the AnnData object structure including X, obs, var, layers, obsm, varm, obsp, varp, uns, and raw components.
    
    **See**: `references/data_structure.md` for comprehensive information on:
    - Core components (X, obs, var, layers, obsm, varm, obsp, varp, uns, raw)
    - Creating AnnData objects from various sources
    - Accessing and manipulating data components
    - Memory-efficient practices
    
    ### 2. Input/Output Operations
    
    Read and write data in various formats with support for compression, backed mode, and cloud storage.
    
    **See**: `references/io_operations.md` for details on:
    - Native formats (h5ad, zarr)
    - Alternative formats (CSV, MTX, Loom, 10X, Excel)
    - Backed mode for large datasets
    - Remote data access
    - Format conversion
    - Performance optimization
    
    Common commands:
    ```python
    # Read/write h5ad
    adata = ad.read_h5ad('data.h5ad', backed='r')
    adata.write_h5ad('output.h5ad', compression='gzip')
    
    # Read 10X data (10x readers live in scanpy, not anndata)
    import scanpy as sc
    adata = sc.read_10x_h5('filtered_feature_bc_matrix.h5')
    
    # Read MTX format (.mtx is variables x observations; transpose so cells are rows)
    adata = ad.io.read_mtx('matrix.mtx').T
    ```
    
    ### 3. Concatenation
    
    Combine multiple AnnData objects along observations or variables with flexible join strategies.
    
    **See**: `references/concatenation.md` for comprehensive coverage of:
    - Basic concatenation (axis=0 for observations, axis=1 for variables)
    - Join types (inner, outer)
    - Merge strategies (same, unique, first, only)
    - Tracking data sources with labels
    - Lazy concatenation (AnnCollection)
    - On-disk concatenation for large datasets
    
    Common commands:
    ```python
    # Concatenate observations (combine samples)
    adata = ad.concat(
        [adata1, adata2, adata3],
        axis=0,
        join='inner',
        label='batch',
        keys=['batch1', 'batch2', 'batch3']
    )
    
    # Concatenate variables (combine modalities)
    adata = ad.concat([adata_rna, adata_protein], axis=1)
    
    # Lazy concatenation — AnnCollection takes AnnData objects (open them backed), not paths
    from anndata.experimental import AnnCollection
    adatas = {p: ad.read_h5ad(f'{p}.h5ad', backed='r') for p in ('data1', 'data2')}
    collection = AnnCollection(adatas, join_obs='outer', label='dataset')
    ```
    
    ### 4. Data Manipulation
    
    Transform, subset, filter, and reorganize data efficiently.
    
    **See**: `references/manipulation.md` for detailed guidance on:
    - Subsetting (by indices, names, boolean masks, metadata conditions)
    - Transposition
    - Copying (full copies vs views)
    - Renaming (observations, variables, categories)
    - Type conversions (strings to categoricals, sparse/dense)
    - Adding/removing data components
    - Reordering
    - Quality control filtering
    
    Common commands:
    ```python
    # Subset by metadata
    filtered = adata[adata.obs['quality_score'] > 0.8]
    hv_genes = adata[:, adata.var['highly_variable']]
    
    # Transpose
    adata_T = adata.T
    
    # Copy vs view
    view = adata[0:100, :]  # View (lightweight reference; writes to it copy-on-write in 0.13+)
    copy = adata[0:100, :].copy()  # Independent copy
    
    # Convert strings to categoricals
    adata.strings_to_categoricals()
    ```
    
    ### 5. Best Practices
    
    Follow recommended patterns for memory efficiency, performance, and reproducibility.
    
    **See**: `references/best_practices.md` for guidelines on:
    - Memory management (sparse matrices, categoricals, backed mode)
    - Views vs copies
    - Data storage optimization
    - Performance optimization
    - Working with raw data
    - Metadata management
    - Reproducibility
    - Error handling
    - Integration with other tools
    - Common pitfalls and solutions
    
    Key recommendations:
    ```python
    # Use sparse matrices for sparse data
    from scipy.sparse import csr_matrix
    adata.X = csr_matrix(adata.X)
    
    # Convert strings to categoricals
    adata.strings_to_categoricals()
    
    # Use backed mode for large files
    adata = ad.read_h5ad('large.h5ad', backed='r')
    
    # Store raw before filtering
    adata.raw = adata.copy()
    adata = adata[:, adata.var['highly_variable']]
    ```
    
    ## Integration with Scverse Ecosystem
    
    AnnData serves as the foundational data structure for the scverse ecosystem:
    
    ### Scanpy (Single-cell analysis)
    AnnData is scanpy's native object — once built/loaded, pass it straight in.
    Preprocessing, dimensionality reduction, clustering, and plotting
    (`sc.pp.normalize_total`, `sc.pp.highly_variable_genes`, `sc.pp.pca`,
    `sc.pp.neighbors`, `sc.tl.umap`, `sc.tl.leiden`, `sc.pl.*`) are **scanpy's
    job, not anndata's** — defer the analysis workflow there.
    ```python
    import scanpy as sc
    
    sc.pp.filter_cells(adata, min_genes=200)  # scanpy mutates the AnnData in place
    # ... continue the analysis pipeline in scanpy
    ```
    
    ### Muon (Multimodal data)
    ```python
    import muon as mu
    
    # Combine RNA and protein data
    mdata = mu.MuData({'rna': adata_rna, 'protein': adata_protein})
    ```
    
    ### PyTorch integration
    ```python
    from anndata.experimental import AnnLoader
    
    # Create DataLoader for deep learning (also accepts an AnnCollection)
    dataloader = AnnLoader(adata, batch_size=128, shuffle=True)
    
    for batch in dataloader:
        X = batch["X"]      # dict-style access; tensors, not attributes
        labels = batch["obs"]["cell_type"]
        # Train model
    ```
    
    ## Common Workflows
    
    ### Single-cell data lifecycle (the anndata-owned parts)
    Load, compute simple QC metrics on `obs`/`var`, snapshot `raw`, subset, and write.
    The normalize/log1p/HVG/cluster steps belong to scanpy — hand off there.
    ```python
    import anndata as ad
    import scanpy as sc
    
    # 1. Load (10x readers live in scanpy)
    adata = sc.read_10x_h5('filtered_feature_bc_matrix.h5')
    
    # 2. Quick QC metrics on obs/var, then mask-subset (pure anndata wrangling)
    adata.obs['n_genes'] = (adata.X > 0).sum(axis=1)
    adata.obs['n_counts'] = adata.X.sum(axis=1)
    adata = adata[(adata.obs['n_genes'] > 200) & (adata.obs['n_counts'] < 50000)].copy()
    
    # 3. Snapshot raw before any gene filtering
    adata.raw = adata.copy()
    
    # 4. Hand off normalization / HVG / clustering to scanpy, then come back:
    #    sc.pp.normalize_total / sc.pp.log1p / sc.pp.highly_variable_genes / ...
    adata = adata[:, adata.var['highly_variable']].copy()  # subset is anndata's job
    
    # 5. Save processed data
    adata.write_h5ad('processed.h5ad', compression='gzip')
    ```
    
    ### Batch integration (concatenate, then defer correction)
    ```python
    # Load and concatenate batches with source labels — this is anndata's job
    adatas = [ad.read_h5ad(p) for p in ['batch1.h5ad', 'batch2.h5ad', 'batch3.h5ad']]
    adata = ad.concat(
        adatas,
        label='batch',
        keys=['batch1', 'batch2', 'batch3'],
        join='inner',
    )
    
    # Batch correction and downstream analysis (combat / pca / neighbors / umap)
    # are scanpy territory — pass `adata` to scanpy from here.
    ```
    
    ### Working with large datasets
    ```python
    # Open in backed mode
    adata = ad.read_h5ad('100GB_dataset.h5ad', backed='r')
    
    # Filter based on metadata (no data loading)
    high_quality = adata[adata.obs['quality_score'] > 0.8]
    
    # Load filtered subset
    adata_subset = high_quality.to_memory()
    
    # Process subset
    process(adata_subset)
    
    # Or process in chunks
    chunk_size = 1000
    for i in range(0, adata.n_obs, chunk_size):
        chunk = adata[i:i+chunk_size, :].to_memory()
        process(chunk)
    ```
    
    ## Troubleshooting
    
    ### Out of memory errors
    Use backed mode or convert to sparse matrices:
    ```python
    # Backed mode
    adata = ad.read_h5ad('file.h5ad', backed='r')
    
    # Sparse matrices
    from scipy.sparse import csr_matrix
    adata.X = csr_matrix(adata.X)
    ```
    
    ### Slow file reading
    Use compression and appropriate formats:
    ```python
    # Optimize for storage
    adata.strings_to_categoricals()
    adata.write_h5ad('file.h5ad', compression='gzip')
    
    # Use Zarr for cloud storage
    adata.write_zarr('file.zarr', chunks=(1000, 1000))
    ```
    
    ### Index alignment issues
    Always align external data on index:
    ```python
    # Wrong
    adata.obs['new_col'] = external_data['values']
    
    # Correct
    adata.obs['new_col'] = external_data.set_index('cell_id').loc[adata.obs_names, 'values']
    ```
    
    ## Additional Resources
    
    - **Official documentation**: https://anndata.readthedocs.io/
    - **Scanpy tutorials**: https://scanpy.readthedocs.io/
    - **Scverse ecosystem**: https://scverse.org/
    - **GitHub repository**: https://github.com/scverse/anndata
    
    Part of the AlterLab Academic Skills suite.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related