Skills › Scientific Agent Skills › anndata › best_practices.md
best_practices.md
550 lines · skills/anndata/references/best_practices.md at 5f2bc03
1# Best Practices
3Guidelines reviewed for AnnData 0.13.4. User-file paths and application-specific `process`/`analyze` functions are illustrative. Small synthetic tests verify the data-structure and native-I/O contracts; they do not validate biological analysis.
Best Practices
Guidelines reviewed for AnnData 0.13.4. User-file paths and application-specific process/analyze functions are illustrative. Small synthetic tests verify the data-structure and native-I/O contracts; they do not validate biological analysis.
Memory Management
Raw lines 5 to 67### Use sparse matrices for sparse data
8```python
9import numpy as np
10from scipy.sparse import csr_matrix, csc_matrix
11import anndata as ad
13# Check data sparsity
14data = np.random.rand(1000, 2000)
15sparsity = 1 - np.count_nonzero(data) / data.size
16print(f"Sparsity: {sparsity:.2%}")
18# Convert to sparse if >50% zeros (anndata 0.12+ requires csr or csc)
19if sparsity > 0.5:
20 adata = ad.AnnData(X=csr_matrix(data))
21else:
22 adata = ad.AnnData(X=data)
24# Measure actual memory: data.nbytes versus CSR data/indices/indptr.nbytes.
25# Density, dtype, index width, and temporary arrays determine savings.
26```
Use sparse matrices for sparse data
import numpy as np
from scipy.sparse import csr_matrix, csc_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 (anndata 0.12+ requires csr or csc)
if sparsity > 0.5:
adata = ad.AnnData(X=csr_matrix(data))
else:
adata = ad.AnnData(X=data)
# Measure actual memory: data.nbytes versus CSR data/indices/indptr.nbytes.
# Density, dtype, index width, and temporary arrays determine savings.Raw lines 7 to 2728### Convert strings to categoricals
29```python
30# Inefficient: string columns use lots of memory
31adata.obs['cell_type'] = ['Type_A', 'Type_B', 'Type_C'] * 333 + ['Type_A']
33# Efficient: convert to categorical
34adata.obs['cell_type'] = adata.obs['cell_type'].astype('category')
36# Convert all string columns
37adata.strings_to_categoricals()
39# Check obs.memory_usage(deep=True); savings depend on category cardinality.
40```
Convert strings to categoricals
# 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()
# Check obs.memory_usage(deep=True); savings depend on category cardinality.Raw lines 28 to 4142### Use backed mode for large datasets
43```python
44# Don't load entire dataset into memory
45adata = ad.read_h5ad('large_dataset.h5ad', backed='r')
47# Work with metadata
48filtered = adata[adata.obs['quality'] > 0.8]
50# Load only filtered subset
51adata_subset = filtered.to_memory()
53# Benefits: Work with datasets larger than RAM
54```
Use backed mode for large datasets
# 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 RAMRaw lines 42 to 55Views vs Copies
Raw lines 56 to 5758### Understanding views
59```python
60# Subsetting creates a view by default
61subset = adata[0:100, :]
62print(subset.is_view) # True
64# Views defer subset copies. In 0.13, .X also uses copy-on-write.
65# Modification can allocate a full subset copy; make ownership explicit.
67# Check if object is a view
68if adata.is_view:
69 adata = adata.copy() # Make independent
70```
Understanding views
# Subsetting creates a view by default
subset = adata[0:100, :]
print(subset.is_view) # True
# Views defer subset copies. In 0.13, .X also uses copy-on-write.
# Modification can allocate a full subset copy; make ownership explicit.
# Check if object is a view
if adata.is_view:
adata = adata.copy() # Make independentRaw lines 58 to 7172### When to use views
73```python
74# Good: Read-only operations on subsets
75mean_expr = adata[adata.obs['cell_type'] == 'T cell'].X.mean()
77# Good: Temporary analysis
78temp_subset = adata[:100, :]
79result = analyze(temp_subset.X)
80```
When to use views
# 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)Raw lines 72 to 8182### When to use copies
83```python
84# Create independent copy for modifications
85adata_filtered = adata[keep_cells, :].copy()
87# Safe to modify without affecting original
88adata_filtered.obs['new_column'] = values
90# Always copy when:
91# - Storing subset for later use
92# - Modifying subset data
93# - Passing to function that modifies data
94```
When to use copies
# 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 dataRaw lines 82 to 95Data Storage Best Practices
Raw lines 96 to 9798### Choose the right format
100**H5AD (HDF5) - Default choice**
101```python
102adata.write_h5ad('data.h5ad', compression='gzip')
103```
104- Fast random access
105- Supports backed mode
106- Good compression
107- Best for: Most use cases
109**Zarr - Cloud and parallel access**
110```python
111import anndata
113# AnnData 0.13.4 defaults to Zarr v3 with automatic sharding.
114adata.write_zarr('data.zarr', chunks=(100, 100))
115```
116- Excellent for cloud storage (S3, GCS)
117- Supports parallel I/O and default Zarr v3 sharding in 0.13.4
118- Good compression
119- Best for: Large datasets, cloud workflows, parallel processing
121**CSV - Interoperability**
122```python
123adata.write_csvs('output_dir/', skip_data=False)
124```
125- Human readable
126- Interoperable tabular export; loses parts of AnnData and may densify X
127- Large file sizes, slow
128- Best for: Sharing with non-Python tools, small datasets
Choose the right format
H5AD (HDF5) - Default choice
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
import anndata
# AnnData 0.13.4 defaults to Zarr v3 with automatic sharding.
adata.write_zarr('data.zarr', chunks=(100, 100))- Excellent for cloud storage (S3, GCS)
- Supports parallel I/O and default Zarr v3 sharding in 0.13.4
- Good compression
- Best for: Large datasets, cloud workflows, parallel processing
CSV - Interoperability
adata.write_csvs('output_dir/', skip_data=False)- Human readable
- Interoperable tabular export; loses parts of AnnData and may densify X
- Large file sizes, slow
- Best for: Sharing with non-Python tools, small datasets
130### Optimize file size
131```python
132# Before saving, optimize:
134# 1. Convert to sparse if appropriate
135from scipy.sparse import csr_matrix, issparse
136if not issparse(adata.X):
137 density = np.count_nonzero(adata.X) / adata.X.size
138 if density < 0.5:
139 adata.X = csr_matrix(adata.X)
141# 2. Convert strings to categoricals
142adata.strings_to_categoricals()
144# 3. Use compression
145adata.write_h5ad('data.h5ad', compression='gzip', compression_opts=9)
147# Compare actual sizes and read/write times; there is no universal reduction factor.
148```
Optimize file size
# 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)
# Compare actual sizes and read/write times; there is no universal reduction factor.Raw lines 130 to 149Backed Mode Strategies
Raw lines 150 to 151152### Read-only analysis
153```python
154# Open in read-only backed mode
155adata = ad.read_h5ad('data.h5ad', backed='r')
157# Perform filtering without loading data
158high_quality = adata[adata.obs['quality_score'] > 0.8]
160# Load only filtered data
161adata_filtered = high_quality.to_memory()
162```
Read-only analysis
# 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()Raw lines 152 to 163164### Read-write modifications
165```python
166# Open in read-write backed mode
167adata = ad.read_h5ad('data.h5ad', backed='r+')
169# Dense HDF5 X supports in-place writes; backed sparse X does not in 0.13.
170import h5py
171if isinstance(adata.X, h5py.Dataset):
172 adata.X[0, 0] = 0
174# Metadata changes are not persisted from backed mode; load and write a new file
175adata_memory = adata.to_memory()
176adata_memory.obs['new_annotation'] = values
177adata.file.close()
178adata_memory.write_h5ad('data_with_annotations.h5ad')
179```
Read-write modifications
# Open in read-write backed mode
adata = ad.read_h5ad('data.h5ad', backed='r+')
# Dense HDF5 X supports in-place writes; backed sparse X does not in 0.13.
import h5py
if isinstance(adata.X, h5py.Dataset):
adata.X[0, 0] = 0
# Metadata changes are not persisted from backed mode; load and write a new file
adata_memory = adata.to_memory()
adata_memory.obs['new_annotation'] = values
adata.file.close()
adata_memory.write_h5ad('data_with_annotations.h5ad')Raw lines 164 to 180181### Chunked processing
182```python
183# Process large dataset in chunks
184adata = ad.read_h5ad('huge_dataset.h5ad', backed='r')
186results = []
187chunk_size = 1000
189for i in range(0, adata.n_obs, chunk_size):
190 chunk = adata[i:i+chunk_size, :].to_memory()
191 result = process(chunk)
192 results.append(result)
194final_result = combine(results)
195```
Chunked processing
# 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)Raw lines 181 to 196Performance Optimization
Raw lines 197 to 198199### Subsetting performance
200```python
201# Boolean mask: clear and aligned to obs
202mask = np.array(adata.obs['quality'] > 0.5)
203subset = adata[mask, :]
205# A boolean Series is also supported; it does not inherently create a view chain
206subset = adata[adata.obs['quality'] > 0.5, :]
208# Integer positions: useful when already available; benchmark actual workloads
209indices = np.where(adata.obs['quality'] > 0.5)[0]
210subset = adata[indices, :]
211```
Subsetting performance
# Boolean mask: clear and aligned to obs
mask = np.array(adata.obs['quality'] > 0.5)
subset = adata[mask, :]
# A boolean Series is also supported; it does not inherently create a view chain
subset = adata[adata.obs['quality'] > 0.5, :]
# Integer positions: useful when already available; benchmark actual workloads
indices = np.where(adata.obs['quality'] > 0.5)[0]
subset = adata[indices, :]Raw lines 199 to 212213### Avoid repeated subsetting
214```python
215# Inefficient: Multiple subset operations
216for cell_type in ['A', 'B', 'C']:
217 subset = adata[adata.obs['cell_type'] == cell_type]
218 process(subset)
220# Alternative: reuse groups when several operations need the same grouping
221groups = adata.obs.groupby('cell_type', observed=True).groups
222for cell_type, indices in groups.items():
223 subset = adata[indices, :]
224 process(subset)
225```
Avoid repeated subsetting
# Inefficient: Multiple subset operations
for cell_type in ['A', 'B', 'C']:
subset = adata[adata.obs['cell_type'] == cell_type]
process(subset)
# Alternative: reuse groups when several operations need the same grouping
groups = adata.obs.groupby('cell_type', observed=True).groups
for cell_type, indices in groups.items():
subset = adata[indices, :]
process(subset)Raw lines 213 to 226227### Use chunked operations for large matrices
228```python
229# Process X in chunks
230for chunk, start, stop in adata.chunked_X(chunk_size=1000):
231 result = compute(chunk)
233# More memory efficient than loading full X
234```
Use chunked operations for large matrices
# Process X in chunks
for chunk, start, stop in adata.chunked_X(chunk_size=1000):
result = compute(chunk)
# More memory efficient than loading full XRaw lines 227 to 235Working with Raw Data
Raw lines 236 to 237238### Store raw before filtering
239```python
240# Original data with all genes
241adata = ad.AnnData(X=counts)
243# Store raw before filtering
244adata.raw = adata.copy()
246# Filter to highly variable genes
247adata = adata[:, adata.var['highly_variable']]
249# Later: access original data
250original_expression = adata.raw.X
251all_genes = adata.raw.var_names
252```
Store raw before filtering
# 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_namesRaw lines 238 to 253254### When to use raw
255```python
256# Use raw for:
257# - Differential expression on filtered genes
258# - Visualization of specific genes not in filtered set
259# - Accessing the snapshot values, which may be counts OR normalized data
260# Check raw_semantics; .raw is not automatically counts or a full object backup.
262# Access raw data
263if adata.raw is not None:
264 gene_expr = adata.raw[:, 'GENE_NAME'].X
265else:
266 gene_expr = adata[:, 'GENE_NAME'].X
267```
When to use raw
# Use raw for:
# - Differential expression on filtered genes
# - Visualization of specific genes not in filtered set
# - Accessing the snapshot values, which may be counts OR normalized data
# Check raw_semantics; .raw is not automatically counts or a full object backup.
# Access raw data
if adata.raw is not None:
gene_expr = adata.raw[:, 'GENE_NAME'].X
else:
gene_expr = adata[:, 'GENE_NAME'].XRaw lines 254 to 268Metadata Management
Raw lines 269 to 270271### Naming conventions
272```python
273# Consistent naming improves usability
275# Observation metadata (obs):
276# - cell_id, sample_id
277# - cell_type, tissue, condition
278# - n_genes, n_counts, percent_mito
279# - cluster, leiden, louvain
281# Variable metadata (var):
282# - gene_id, gene_name
283# - highly_variable, n_cells
284# - mean_expression, dispersion
286# Embeddings (obsm):
287# - X_pca, X_umap, X_tsne
288# - X_diffmap, X_draw_graph_fr
290# Follow conventions from scanpy/scverse ecosystem
291```
Naming conventions
# 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 ecosystemRaw lines 271 to 292293### Document metadata
294```python
295# Store metadata descriptions in uns
296adata.uns['metadata_descriptions'] = {
297 'cell_type': 'Cell type annotation from automated clustering',
298 'doublet_score': 'Scrublet predicted doublet score; higher suggests a doublet',
299 'batch': 'Experimental batch identifier'
300}
302# Store processing history
303adata.uns['processing_steps'] = [
304 'Raw counts loaded from 10X',
305 'Filtered: n_genes > 200, n_counts < 50000',
306 'Normalized to 10000 counts per cell',
307 'Log transformed'
308]
309```
Document metadata
# Store metadata descriptions in uns
adata.uns['metadata_descriptions'] = {
'cell_type': 'Cell type annotation from automated clustering',
'doublet_score': 'Scrublet predicted doublet score; higher suggests a doublet',
'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'
]Raw lines 293 to 310Reproducibility
Raw lines 311 to 312313### Set random seeds
314```python
315import numpy as np
317# Set seed for reproducible results
318np.random.seed(42)
320# Document in uns
321adata.uns['random_seed'] = 42
322```
Set random seeds
import numpy as np
# Set seed for reproducible results
np.random.seed(42)
# Document in uns
adata.uns['random_seed'] = 42Raw lines 313 to 323324### Store parameters
325```python
326# Store analysis parameters in uns
327adata.uns['pca'] = {
328 'n_comps': 50,
329 'svd_solver': 'arpack',
330 'random_state': 42
331}
333adata.uns['neighbors'] = {
334 'n_neighbors': 15,
335 'n_pcs': 50,
336 'metric': 'euclidean',
337 'method': 'umap'
338}
339```
Store parameters
# 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'
}Raw lines 324 to 340341### Version tracking
342```python
343import sys
344from importlib.metadata import version
346# Store package versions (anndata.__version__ deprecated in 0.12.3)
347adata.uns['versions'] = {
348 'anndata': version('anndata'),
349 'scanpy': version('scanpy'),
350 'numpy': version('numpy'),
351 'python': sys.version,
352}
353```
Version tracking
import sys
from importlib.metadata import version
# Store package versions (anndata.__version__ deprecated in 0.12.3)
adata.uns['versions'] = {
'anndata': version('anndata'),
'scanpy': version('scanpy'),
'numpy': version('numpy'),
'python': sys.version,
}Raw lines 341 to 354Error Handling
Raw lines 355 to 356357### Check data validity
358```python
359# Verify dimensions
360assert adata.n_obs == len(adata.obs)
361assert adata.n_vars == len(adata.var)
362assert adata.X.shape == (adata.n_obs, adata.n_vars)
364# Check for NaN values
365has_nan = np.isnan(adata.X.data).any() if issparse(adata.X) else np.isnan(adata.X).any()
366if has_nan:
367 print("Warning: Data contains NaN values")
369# Check for negative values (if counts expected)
370has_negative = (adata.X.data < 0).any() if issparse(adata.X) else (adata.X < 0).any()
371if has_negative:
372 print("Warning: Data contains negative values")
373```
Check data validity
# 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")Raw lines 357 to 374375### Validate metadata
376```python
377# Check for missing values
378missing_obs = adata.obs.isnull().sum()
379if missing_obs.any():
380 print("Missing values in obs:")
381 print(missing_obs[missing_obs > 0])
383# Verify indices are unique
384assert adata.obs_names.is_unique, "Observation names not unique"
385assert adata.var_names.is_unique, "Variable names not unique"
387# Check metadata alignment
388assert len(adata.obs) == adata.n_obs
389assert len(adata.var) == adata.n_vars
390```
Validate metadata
# 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_varsRaw lines 375 to 391Integration with Other Tools
Raw lines 392 to 393394### Scanpy integration
395```python
396import scanpy as sc
398# AnnData is native format for scanpy
399sc.pp.filter_cells(adata, min_genes=200)
400sc.pp.filter_genes(adata, min_cells=3)
401sc.pp.normalize_total(adata, target_sum=1e4)
402sc.pp.log1p(adata)
403sc.pp.highly_variable_genes(adata)
404sc.pp.pca(adata)
405sc.pp.neighbors(adata)
406sc.tl.umap(adata)
407```
Scanpy integration
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)Raw lines 394 to 408409### Pandas integration
410```python
411import pandas as pd
413# Convert only a manageable subset: to_df densifies sparse data
414df = adata[:100, :100].to_df()
416# Create from DataFrame
417adata = ad.AnnData(df)
419# Work with metadata as DataFrames
420assert external_metadata.index.is_unique
421adata.obs = adata.obs.join(external_metadata, how='left', validate='one_to_one')
422```
Pandas integration
import pandas as pd
# Convert only a manageable subset: to_df densifies sparse data
df = adata[:100, :100].to_df()
# Create from DataFrame
adata = ad.AnnData(df)
# Work with metadata as DataFrames
assert external_metadata.index.is_unique
adata.obs = adata.obs.join(external_metadata, how='left', validate='one_to_one')Raw lines 409 to 423424### PyTorch integration
426`AnnLoader` is deprecated; use the official [annbatch.Loader migration tutorial](https://anndata.readthedocs.io/en/stable/tutorials/notebooks/annbatch.html).
427No training, accelerator, or third-party version combination was executed here.
PyTorch integration
AnnLoader is deprecated; use the official annbatch.Loader migration tutorial. No training, accelerator, or third-party version combination was executed here.
Common Pitfalls
Raw lines 429 to 430431### Pitfall 1: Modifying views
432```python
433# In 0.13, assigning .X to a view materializes that view (copy-on-write).
434subset = adata[:100, :]
435subset.X = new_data # Can allocate unexpectedly; older releases behaved differently
437# Correct: Copy before modifying
438subset = adata[:100, :].copy()
439subset.X = new_data # Independent copy
440```
Pitfall 1: Modifying views
# In 0.13, assigning .X to a view materializes that view (copy-on-write).
subset = adata[:100, :]
subset.X = new_data # Can allocate unexpectedly; older releases behaved differently
# Correct: Copy before modifying
subset = adata[:100, :].copy()
subset.X = new_data # Independent copyRaw lines 431 to 441442### Pitfall 2: Index misalignment
443```python
444# Wrong: Assuming order matches
445external_data = pd.read_csv('data.csv')
446adata.obs['new_col'] = external_data['values'] # May misalign!
448# Correct: Align on index
449adata.obs['new_col'] = external_data.set_index('cell_id').loc[adata.obs_names, 'values']
450```
Pitfall 2: Index misalignment
# 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']Raw lines 442 to 451452### Pitfall 3: Mixing sparse and dense
453```python
454# SciPy sparse + nonzero scalar raises NotImplementedError.
455# Adding 1 to .data alone changes only stored entries, NOT every matrix element.
456# A zero-preserving operation such as log1p can safely preserve sparsity:
457from scipy.sparse import issparse
458if issparse(adata.X):
459 result = adata.X.copy()
460 result.data = np.log1p(result.data)
461else:
462 result = np.log1p(adata.X)
463```
Pitfall 3: Mixing sparse and dense
# SciPy sparse + nonzero scalar raises NotImplementedError.
# Adding 1 to .data alone changes only stored entries, NOT every matrix element.
# A zero-preserving operation such as log1p can safely preserve sparsity:
from scipy.sparse import issparse
if issparse(adata.X):
result = adata.X.copy()
result.data = np.log1p(result.data)
else:
result = np.log1p(adata.X)Raw lines 452 to 464465### Pitfall 4: Not handling views
466```python
467# An in-memory view keeps its parent alive even if the local name is deleted.
468subset = adata[mask, :]
469del adata
470# Copy when independent ownership is required:
471subset = subset.copy()
472# For backed data, keep the HDF5 file open until .to_memory() completes.
473```
Pitfall 4: Not handling views
# An in-memory view keeps its parent alive even if the local name is deleted.
subset = adata[mask, :]
del adata
# Copy when independent ownership is required:
subset = subset.copy()
# For backed data, keep the HDF5 file open until .to_memory() completes.Raw lines 465 to 474475### Pitfall 5: Ignoring memory constraints
476```python
477# Wrong: Loading huge dataset into memory
478adata = ad.read_h5ad('100GB_file.h5ad') # OOM error!
480# Correct: Use backed mode
481adata = ad.read_h5ad('100GB_file.h5ad', backed='r')
482subset = adata[adata.obs['keep']].to_memory()
483```
Pitfall 5: Ignoring memory constraints
# 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()Raw lines 475 to 484485## Workflow Example
487Complete best-practices workflow:
489```python
490import anndata as ad
491import numpy as np
492from scipy.sparse import csr_matrix, issparse
494# 1. Load with backed mode if large
495source = ad.read_h5ad('data.h5ad', backed='r')
496adata = source
498# 2. Quick metadata check without loading data
499print(f"Dataset: {adata.n_obs} cells × {adata.n_vars} genes")
501# 3. Filter based on metadata
502high_quality = adata[adata.obs['quality_score'] > 0.8]
504# 4. Load filtered subset to memory
505adata = high_quality.to_memory()
506source.file.close()
508# 5. Convert to optimal storage types (csr/csc sparse only since 0.12)
509adata.strings_to_categoricals()
510if not issparse(adata.X):
511 density = np.count_nonzero(adata.X) / adata.X.size
512 if density < 0.5:
513 adata.X = csr_matrix(adata.X)
515# 6. Store raw before filtering genes
516adata.raw = adata.copy()
518# 7. Filter to highly variable genes
519adata = adata[:, adata.var['highly_variable']].copy()
521# 8. Document processing
522adata.uns['processing'] = {
523 'filtered': 'quality_score > 0.8',
524 'n_hvg': adata.n_vars,
525 'matrix_semantics': 'retain and document input transformations'
526}
528# 9. Save optimized
529adata.write_h5ad('processed.h5ad', compression='gzip')
530```
Workflow Example
Complete best-practices workflow:
import anndata as ad
import numpy as np
from scipy.sparse import csr_matrix, issparse
# 1. Load with backed mode if large
source = ad.read_h5ad('data.h5ad', backed='r')
adata = source
# 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()
source.file.close()
# 5. Convert to optimal storage types (csr/csc sparse only since 0.12)
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,
'matrix_semantics': 'retain and document input transformations'
}
# 9. Save optimized
adata.write_h5ad('processed.h5ad', compression='gzip')Raw lines 485 to 531532## Provenance and compatibility
534- `raw` retains its feature axis on gene filtering, but follows cell filtering. Named
535 layers follow both axes. Store counts separately if later analyses need all genes.
536- AnnData 0.13 includes X as `layers[None]`; use `key is not None` for named-layer
537 iteration and `layers.clear(keep_x=True)` to retain X. Check third-party exporters
538 before upgrading: this change breaks some older string-key assumptions.
539- Keep stable feature IDs separate from symbols; making duplicate names unique is
540 a syntactic repair, not biological deduplication. Record species and genome build.
541- H5AD backed reads can still load metadata and layers; Zarr `read_zarr` is eager.
542 Budget memory before `to_memory`, `to_df`, transposition, scaling, or dense export.
543- Reopen native output and compare identifiers in order, dtypes, categories, selected
544 matrix values, named layers, raw gene IDs, and source/transformation provenance.
546Sources: [AnnData 0.13 release notes](https://anndata.readthedocs.io/en/stable/release-notes/),
547[raw](https://anndata.readthedocs.io/en/stable/generated/anndata.AnnData.raw.html),
548[write_csvs](https://anndata.readthedocs.io/en/stable/generated/anndata.AnnData.write_csvs.html),
549[read_h5ad](https://anndata.readthedocs.io/en/stable/generated/anndata.io.read_h5ad.html),
550and installed AnnData 0.13.4 source.
Provenance and compatibility
rawretains its feature axis on gene filtering, but follows cell filtering. Named layers follow both axes. Store counts separately if later analyses need all genes.- AnnData 0.13 includes X as
layers[None]; usekey is not Nonefor named-layer iteration andlayers.clear(keep_x=True)to retain X. Check third-party exporters before upgrading: this change breaks some older string-key assumptions. - Keep stable feature IDs separate from symbols; making duplicate names unique is a syntactic repair, not biological deduplication. Record species and genome build.
- H5AD backed reads can still load metadata and layers; Zarr
read_zarris eager. Budget memory beforeto_memory,to_df, transposition, scaling, or dense export. - Reopen native output and compare identifiers in order, dtypes, categories, selected matrix values, named layers, raw gene IDs, and source/transformation provenance.
Sources: AnnData 0.13 release notes, raw, write_csvs, read_h5ad, and installed AnnData 0.13.4 source.
Raw lines 532 to 550