Changelog¶
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Milestones are not releases.
ROADMAP.mdtracks progress inv0.N.0milestones, named after the slice of Seurat they port. Those are planning labels and never become version numbers on their own. Before 0.9.0 the two sequences had drifted a long way apart — the tags stopped at 0.2.0 while the milestones had run through v0.9.0, with a milestone able to span releases (v0.7.0's spatial loaders shipped in 0.1.1 while the rest of it was still unreleased). The 0.9.0 release below closes that entire gap in one jump, which is why its version number happens to match the milestone it completes — a coincidence of this one release, not a policy of matching them going forward.
Unreleased¶
Added¶
-
R's labelled PBMC 3k UMAP,
r_11_umap_labeled.png.pbmc3k_verify.Rdrew no UMAP, so there was no R panel from the same run to set beside Truecell's11_umap_labeled.png. It now draws one, for the Frontiers paper's Figure 3A/B. It uses the same title and size, and the cell types in Truecell's order, radix-sorted so the locale cannot reorder them: underen_US.UTF-8, R's default sort puts "Naive CD4 T" before "NK", which would have swapped two colours. The tutorial page keeps the published vignette's image on R's side, and the PBMC 3k comparison's numbers do not change. -
composition_test(sample_col=)tests per-sample proportions (#144). The Fisher test counts every cell as a replicate, so with thousands of cells nearly every difference is significant, including one that is only donor-to-donor variation. In a simulation with no condition effect (12 donors, 6 against 6, each with its own cell-type proportions) at least one cell type came out BH-significant in 35 of 40 runs. The unit that replicates is the donor. sample_colnames the sample each cell came from. Each group's proportion in each sample is compared between the two levels with a two-sided Mann-Whitney test, BH adjusted across groups, as before. In the same simulation it reports a significant type in 1 of 40 runs, and it still finds a type that is three times as common in one condition in 10 of 10.prop_<level>is then the mean of the per-sample proportions.odds_ratioandchisq_pare cell-level quantities and are not reported, anddf.attrscarriesmethod,sample_colandn_samples.- A sample in both levels is an error, and so is a level none of whose cells has a sample. A cell with no sample is left out. A design too small to reach significance, such as three samples against three, whose smallest possible p is 0.1, warns.
- Without
sample_colnothing changes except the docstring, which now says what the p-values assume, anddf.attrs['method'].
Removed¶
- Sixteen names from
truecell.generics, none of which ever did anything (#142). Each was declared with only theNotImplementedErrorfallback, so each raised for every type of object and no working code changes. - R's constructors
create_truecell_object,create_assay_object,create_fov,create_centroidsandcreate_segmentation. The top-level functions of the same names are the constructors. They take their input by keyword, which a dispatcher on the first positional argument cannot. - Nothing in the port is behind
as_segmentation,as_seurat,assay_class,default_dim_reduc,default_fov,hvf_info,keys,list_to_s4ands4_to_list. The last two are R's S4 plumbing, andHVFInfo()'s table is the per-feature columns ofassay.meta_data. check_matrixandmatch_cellshad only internal helpers behind the same names, and those do something other than R'sCheckMatrixandMatchCells.docs/api/index.mdandtests/test_docs.pycount the generics that are module-only: 65 became 54.
Fixed¶
as_anndatano longer fails on an object from the standard workflow (#141).scale_data()scales only the variable features unless it is given others, soscale.datahas one row per variable feature, and AnnData rejects a layer that is not as wide asvar.as_anndataraised aValueErrorfrom inside AnnData on any object that had been throughnormalize_data,find_variable_featuresandscale_data, in either assay class.- A layer with fewer features than the assay is now left out, with a warning that
names it and says how to keep it:
scale_data(obj, features=obj.feature_names())before converting, which scales every feature and exports the layer as before. - It is left out rather than padded because
from_anndataturns every layer back into an assay layer, and padded rows would read back as scaled values that never were. -
No test had converted an object that had been scaled.
tests/test_anndata_compat.pynow does, for both assay classes, anddocs/interop.mdsays what is left out. -
The CITE-seq tutorial's ADT-weight violins read as one comparison. Reviewer 2 of the Frontiers paper found R's and Truecell's panels of figure 10 in different orders and colours, which defeats a side-by-side. Both scripts now draw the cell types in one order,
vln_plot's, and colour each from Seurat'shue_palover all twelve labelsannotate_cellscan return. The DC / Mono cluster that only R's clustering finds therefore moves no other colour. Neither draws points, the R script adds the median barvln_plotdraws, and the titles and y-axis labels match. The CITE-seq comparison's numbers do not change. - R also writes
r_10_adt_weight_by_celltype_manuscript.png, laid out 3.30 in wide as the paper prints it, with its type at the sizes Truecell's panel prints at. With Seurat's defaults at that width the y-axis labels overprinted. -
tests/test_multimodal_tutorial.pykeeps the two scripts' label lists equal, and the smoke suite checks the figure's layout at the width the paper uses. -
Plots follow a categorical column's order, so a lineage-ordered
dot_plotis possible, andreorder_identhas an effect (#143). In Seurat the order of a factor's levels decides the order of the groups inDotPlotandVlnPlot.dot_plot,vln_plot,dim_plot,feature_scatter,do_heatmapandridge_plotsorted the groups themselves, whatever the categories said, so afterobj.reorder_ident("score")they still drew Alpha, Mu, Zeta. - A categorical column, or the active identity, is drawn in its category order. The
colours follow, as
hue_palis assigned in that order in R. - Categories nobody chose are not an order, so they are not followed.
pd.Categorical(values)andrename_identsleave the string-sorted categories "1", "10", "2", and those still sort numbers first, then names, as before. - Categories with no cells are not drawn, and a missing value is still last.
- Nothing changes for an existing workflow:
find_clustersalready orders its categories numbers then names, and no tutorial sets a category order. -
image_dim_plotandspatial_dim_plotstill sort alphabetically, whichROADMAP.mdnow lists. -
dim_heatmap, the plots that takelayer=, andadd_module_scoreread the wrong rows ofscale.data(#140).scale_data()scales only the variable features unless it is given others, so row i ofscale.datais not feature i of the assay.dim_heatmapand the helper behinddot_plot,feature_plot,feature_scatter,ridge_plot,vln_plot,image_feature_plotandspatial_feature_plotstill found a gene by its place in the assay's full list. That raisedIndexErrorwhen the place fell past the last scaled row, and drew another gene's row under the right label when it did not. dim_heatmapon the standard workflow raised, or drew rows that were none of the genes it labelled.feature_scatter(layer="scale.data")plotted two other genes.add_module_score(layer="scale.data")had the same lookup, found while checking the other users oflayer=. It raisedIndexErroron the standard workflow.- A gene that is in the assay but not in the layer now raises a
KeyErrorthat names the layer and how many of the assay's features it holds, instead of "not found in assay". -
Nothing changes for a full-width layer such as
data. The plotting tests all scaled every feature, which is why none of them saw this;tests/test_layer_feature_alignment.pyuses the standard workflow and checks that its own fixture can tell the two lookups apart. -
feature_plot's quantile cutoffs are Seurat's, somin_cutoff="q05", max_cutoff="q95"works for a sparse gene (#145). That call, the one the skills recommend for a washed-out scale, collapsed the colour bar of a gene detected in under 5% of cells to (0, 1e-9): every expressing cell the same colour, and no sign anything was wrong. The committed SCTransform tutorial figure showed it, onXCL1. - Seurat's
SetQuantiletakes the percentile over the cells that express the feature, the values above zero.feature_plottook it over every cell, so a sparse gene's 5th and 95th percentiles were both 0. The values are pinned toSeurat:::SetQuantileon Seurat 5.5.1 intests/test_feature_plot_cutoffs.py, negatives and all-zero features included. - Only the strings
"q05"and"q95"were recognised, and"q99"raised aTypeErrorfrom comparing a string with a float. Anyqand one or two digits works now, as in Seurat, and anything else is aValueErrorthat says what is allowed. - Cutoffs that still leave the scale no range, whether equal, inverted, or because every expresser has the same value, now warn and draw the feature over its own range, instead of one colour.
-
tutorials/figures_sctransform/03_sct_featureplots_1.pngand04_sct_featureplots_2.pngare redrawn. The other four figures from the same script are identical to the pixel. The CITE-seq ADT panel does not change: every cell has a positive CLR value there, so the cells that express a protein are all of them, and R's panel already used these quantiles. -
The generics the docs list now work (#142).
truecell.genericsdeclared 72 functions and 39 had only the fallback, so they raisedNotImplementedErrorfor every object.skills/truecell/reference/api-map.mdlisted many as the way to do things, anddocs/api/generics.mdsays each is the same code path as the object's own method. - Twenty-three now delegate to what already existed:
assay_names,calc_n,cast_assay,reorder_ident,set_ident,tool,set_tool,version,set_layer_data,set_assay_data,set_loadings,set_default_assay,set_default_layer,set_variable_features,set_key,misc,set_misc, and the spatialas_centroids,crop,overlay,default_boundary,get_molecules, pluscommand. Arguments are handed through untouched, so a method's signature has no second copy to drift. commandreads the command log as R'sCommand()does: no argument gives the names of the commands that have run, a name gives that entry, and a second argument gives one of its parameters. An unknown command or parameter is an error, as in R, and a command run twice appears once, holding its latest run, as in R's named list.tests/test_generics.pyfails on a generic with no implementation, and on a mismatch in either direction between the module and the api-map's list.
Documentation¶
- The README's roadmap and
ROADMAP.mddescribe 2.0.0. Both still read as of 0.9.0. - The README adds a row for each release since.
ROADMAP.mdmarks the lockfile decision made in 1.1.0 and gives the currentmypycount.- It records that Seurat has since fixed the Visium
Radius()NULLupstream, and that Seurat keeps the spot diameter in its radius slot on purpose. - It ends with the items still open after 2.0.0.
[2.0.0] - 2026-09-18¶
The release the Frontiers revision asked for. Re-running the paper against 1.2.0 found defects, and defaults that departed from Seurat 5's; this release fixes the defects, brings the defaults to Seurat's, and adds a test that fails on any default that differs from Seurat's without a written reason.
A major version, because default results change.
- Marker calls default to Seurat 5's thresholds, so they return more genes, and
sort them in Seurat 5's order.
- find_clusters runs Seurat's own modularity optimiser, at its default
resolution of 0.8, so every default partition moves.
- find_markers(test_use="deseq2") is Seurat's per-cell test.
- run_umap embeds on cosine distance, under Seurat 5's key.
- Feature names have _ and | rewritten to -.
- The imaging loaders build the cells and images that Seurat's readers build.
- Cell-cycle control genes, the Mixscape and Moran's I layers, and the
integration anchor features are Seurat's.
Each is marked BREAKING below.
New alongside: spatial coordinates through as_anndata, show_versions() for
crash reports, select_integration_features, repelled plot labels, and a figure
layout check.
Checked before release: every tutorial was compared against 1.2.0 on the same machine and run again on Linux x86-64, and the performance report was re-measured.
Added¶
tools/compare_defaults.py: every default checked against Seurat's. It reads the formals of the 83 Seurat and SeuratObject functions behind 67 ported ones, dumped from Seurat 5.5.1 intotests/data/seurat_formals.json, andtests/test_default_parity.pyfails on any shared argument whose default differs without a written reason, and on a reason that no longer describes a live difference. Beyond the marker thresholds below it found 52 differences: 11 the same value spelled differently, 14 plotting choices, 6 arguments Seurat gives a default and truecell requires, 12 kept on purpose, and 9 real mismatches that the next changes in this series fix (run_umap's metric and key,run_ica's key,cell_cycle_scoring's control-gene count,dim_heatmap's cell count, the mixscape and Moran's I layers, integration anchor features, andfind_clusters' resolution).select_integration_features, Seurat'sSelectIntegrationFeatures: genes ranked by how many datasets call them variable, ties by their median rank and then by name. Checked against R on lists built so the cut falls inside each kind of tie.as_anndatacarries space, andfrom_anndatareads it back, in the layout Scanpy and Squidpy use. Each cell's (x, y) goes toobsm["spatial"], from the first image that places it, and that image's name toobs["fov"]. AVisiumV2's tissue image and scale factors go touns["spatial"][image]. 1.2.0 wrote none of it. The Frontiers revision foundobsmempty for a 36,602-cell Xenium slide, which left no way from a truecell object into Squidpy or SpatialData.- On 10x's V1 mouse-brain slide,
as_anndata(load_visium(...))matchesscanpy.read_visiumfor all 2,695 spots: the same int64 positions, scale factors and lowres image. from_anndataturnsread_visium's own output into aVisiumV2that matchesload_visium.image_resolution=picks which image to keep.- Objects from all four loaders (Xenium, Visium, CosMx, MERSCOPE) come back from an h5ad round trip with every image unchanged.
- A new docs page, AnnData, Scanpy and SpatialData, gives recipes for cells, cell polygons and a Visium image. Each was checked through zarr with spatialdata 0.8.0.
- Both functions stay on
truecell.compat.anndata, and the README now shows that import rather than listing them as if they were top-level. as_anndatatakesspatial_key=andfov_key=, the namesfrom_anndataalready read, andScaleFactors.to_dict()andScaleFactors.from_dict()convert to and fromscalefactors_json.json's keys.Segmentation.as_centroids(), SeuratObject'sas(segmentation, "Centroids"): each ring's area centroid, or its first vertex when the ring has no area. It is within 5.1e-16 of SeuratObject 5.4.0 on 40 random polygons and exact on the rings with no area.as_anndatauses it for an image that has only a segmentation.truecell.show_versions(), for crash reports. A reviewer's segmentation fault inrun_umaparrived with nothing to narrow it down, and did not reproduce.- It prints where the running truecell lives and how it was installed, and the platform, including Rosetta 2 and free-threaded builds.
- It lists the stack's versions and the installer behind each, numba's threading
layer, the OpenMP and BLAS runtimes
threadpoolctlfinds loaded, and the environment variables that change them. - It warns when LLVM's OpenMP runtime is loaded twice, and on Rosetta 2 and free-threaded builds.
- numba is examined in child processes, so the call cannot pick a threading layer in the session it reports on, or crash it.
- A Troubleshooting page. It covers
-X faulthandler,NUMBA_THREADING_LAYER=workqueue, and keeping conda's and pip's OpenMP runtimes apart. It also covers why an x86_64 Python on Apple Silicon cannot install[analysis](numba has published no Intel macOS wheels since 0.63), and free-threaded 3.14t. - A macOS arm64 canary.
.github/workflows/macos-canary.ymlruns the home-page block weekly on macOS 15 under-X faulthandler, in four environments: pip,uv sync --locked, conda-forge, and conda-forge's numba beside pip.tools/run_docs_snippet.pyreads the block out ofdocs/index.md, so the canary runs what the site shows, and a crash's traceback names the line on the page. On its first run all four environments ran the block to the end. In the mixed one, numba ran on the OpenMP layer beside a second copy of the runtime. repelfordim_plotandvariable_feature_plot, Seurat'sDimPlot(repel = TRUE)andLabelPoints(repel = TRUE). Labels move apart, stay inside the panel, cover no other group's median and at most half of any group's cells, each as close to where it belongs as that allows. A line joins a label that has moved away to its group, and goes round the other labels wherever the panel leaves room. ggrepel is GPL-3, so this is not a port of it: the placement (truecell/_repel.py) is greedy and deterministic, where ggrepel starts from random jitter. It is worked out whenever the figure is drawn, so it still holds after the figure is resized or printed smaller. Off by default, as in Seurat.- A layout check for figures,
tests/_layout.py. Reviewer 3 asked for a systematic answer to overlapping annotations rather than figures nudged by hand; this is the Frontiers revision's figure check, upstreamed. It fails a figure on text over text, text off the canvas, a legend over markers or bars, and an annotation over a legend, a bar or markers. A label drawn on the data may cover cells, but not more than half of a group. Run over the 94 figures the tutorial generators draw, it failed 18 at the size the tutorials show them, with 97 problems; all are fixed (see Fixed). CI's tutorials job checks the pbmc3k annotated UMAP on the real data, at its own size and at the 0.55 the manuscript prints it at. The opt-in smoke suite does the same for the integration scoreboard. - The tutorials on a second machine. Before this release all eighteen were run
again, unchanged, on Linux x86-64 (Ubuntu 24.04, R 4.6.1 on OpenBLAS, the same
uv.lock), and the tutorial index gains a section with both machines side by side. truecell's deterministic steps gave the same cells, variable genes, clusters, labels and marker rows on both. Seurat's answers moved, mostly towards truecell's: CITE-seq's RNA clusters went from 16 on the Mac to 15 on Linux, PBMC 3k's cluster agreement from ARI 0.928 to 0.969, cell hashing from 99.81 % to 100 %. truecell's random steps moved too (the sketch, JackStraw's permutations), and both tools' anchor sets. The pages keep the Mac's numbers. - Every public name is in a skill, and a test keeps it so. Three of 1.2.0's four
new functions reached the API map but no skill a reader loads. The README and the
skills caught up after that release, and
tests/test_docs.pynow fails when an export oftruecellappears in no skill.
Changed¶
- BREAKING:
find_markers,find_all_markersandfind_conserved_markersdefault to Seurat 5'smin_pct=0.01andlogfc_threshold=0.1. They carried Seurat 4's 0.1 and 0.25, so a default call returned fewer genes thanFindMarkersorFindAllMarkersdid. Calls that pass both thresholds are unaffected. - BREAKING: marker tables come back in Seurat 5.5.1's row order. Rows sort by
p_valand then by the larger|pct.1 - pct.2|, as bothFindMarkersandFindAllMarkersdo.find_all_markersbroke ties on descendingavg_log2FC, older Seurat's rule, andfind_markersdid not break them at all. The strongest markers are the ones that tie, so this decides "the top N".roctables sort bypowerand thenmyAUC. pct.1andpct.2are rounded to three decimals, as Seurat'sFoldChangerounds them, beforemin_pctfilters on them. R's rounding is neither numpy's nor Python's — over all 11,624 fractions k/n tried, numpy'srounddiffered on 260 and Python's on 234 — so it is reproduced exactly. A gene in 19 of 2,000 cells now passesmin_pct=0.01, as in Seurat.find_all_markers(test_use="roc")filters onmyAUCat Seurat's 0.7. Seurat swaps its defaultreturn.threshof 0.01 for 0.7 when the test is ROC; truecell kept every gene with an AUC above 0.01 or below 0.99, which is nearly all of them.- BREAKING: feature names are rewritten the way Seurat rewrites them.
create_truecell_object,create_assay_objectandcreate_assay5_objectnow replace_and|in the feature names they are given with-, and warn, as SeuratObject'sCreateAssayObjectandCreateAssay5Objectdo (both throughCheckFeaturesNames). The same gene used to carry two names across the tools — pbmc3k'sY-RNAin Seurat againstY_RNAhere — so a ported script selecting it by name raisedKeyError, and every cross-tool gene-set comparison counted it as a disagreement. On pbmc3k two such genes survive filtering, so the DE tutorial now compares 13,714 genes with Seurat rather than 13,712. Names generated for an unnamed matrix (feature_0, ...) are left alone, since Seurat always has rownames, andfrom_anndatakeeps names exactly as the AnnData holds them, so a round trip renames nothing. The underscore half had sat on an unmerged branch since August; the Frontiers revision found it had never shipped. - BREAKING:
run_umapdefaults to Seurat's cosine metric and Seurat 5's key.RunUMAPembeds on cosine distance. truecell had kept umap-learn's euclidean, so a default call built its neighbour graph on a different distance from Seurat's. The key now comes from the reduction name by SeuratObject'sKey()rule, as inRunUMAP:"umap"givesumap_1andumap_2where truecell wroteUMAP_1, and"wnn_umap"giveswnnumap_1. Passreduction_key="UMAP_"to keep the old column names, ormetric="euclidean"for the old layout. - BREAKING:
run_icanames its componentsIC_1,IC_2, …, the keyRunICAuses, rather thanICA_. dim_plotandfeature_plottitle their axes by the reduction's key, asDimPlotandFeaturePlotdo:PC_1,umap_1,tSNE_1. truecell upper-cased the reduction's name instead, so a PCA plot readPCA_1, a column neither tool has.- BREAKING:
cell_cycle_scoringdraws as many control genes as the smaller gene set has, and calls phases by Seurat's rule.CellCycleScoringpassesctrl = min(length(s.features), length(g2m.features))toAddModuleScore; truecell passedAddModuleScore's own 100, and its docstring called that Seurat's default. A cell isG1only when both scores are below zero, and two scores tied for the highest are"Undecided", a call truecell never made: a cell whose scores were both exactly 0 wasG1here. On THP-1 the phase split moves onto Seurat's (G1 70.3 / S 16.9 / G2M 12.7 % against 70.8 / 16.5 / 12.8 %), and per-cell agreement falls from 96.62 % to 95.86 %. That is the noise floor at 40 control genes: truecell agrees with itself across NumPy seeds 95.8 % of the time. dim_heatmapshows every cell by default, highest score on the left, asDimHeatmapdoes whencellsis leftNULL. truecell showed 500, lowest score first. The cells, and their order, now follow SeuratObject'sTop, checked against R on ten cases including odd counts and ties.- BREAKING:
run_mixscapescales each target gene's DE genes before its mixture, asRunMixscape's defaultslot = "scale.data"does. truecell projected the unscaled signature. Either value oflayerreads the signature's data layer, as Seurat does, and any other value now raises. On the THP-1 screen, per-cell agreement with Seurat moves from 97.46 % to 97.68 % (527 to 481 of 20,729 cells differ). - BREAKING:
find_spatially_variable_featuresreadsscale.databy default, asFindSpatiallyVariableFeaturesdoes on an object, so it ranks the scaled features and raises, pointing atscale_data()orlayer="data", when the assay has no scale.data layer. Moran's I is unchanged when a gene is shifted and rescaled, so the statistic ondatadiffers only whereScaleDataclipped a value at 10. The tutorials and tests passlayer="data", which is what their R references compute on. - BREAKING:
find_integration_anchorspicks 2000 anchor features withselect_integration_featuresby default, asFindIntegrationAnchors'anchor.features = 2000does. truecell intersected the objects' variable features instead, which leaves out every gene variable in some datasets but not all. A list still means those features;integrate_layers, whose batches share one list, gets that list back unchanged. - BREAKING:
find_markers(test_use="deseq2")is Seurat's DESeq2 test.FindMarkers(test.use = "DESeq2")gives DESeq2 one column per cell. truecell requiredsample_col, summed counts per sample and ran pydeseq2's defaults, so it answered a different question and returned different genes. Now the defaultsample_col=Nonetests every cell as a replicate, andsample_colsums each sample's cells first, then runs the same test. Four of pydeseq2's choices give way to DESeq2's: the local dispersion trend, written from the published method because locfit, which DESeq2 fits it with, is GPL; gene-wise dispersions set to the minimum where the likelihood is flat, which keeps them out of the trend fit as DESeq2 does; no Cook's refit; and fitted means floored at 0.5 in the Wald standard error. As in Seurat,logfc_thresholdno longer pre-filters DESeq2's genes,p_val_adjis Bonferroni over every feature where it was pydeseq2's Benjamini-Hochbergpadj, andavg_log2FCis Seurat's fold change on the data layer rather than DESeq2'slog2FoldChange. A matrix in which every gene has a zero raises, asestimateSizeFactorsstops. On Seurat's ifnb pseudobulk vignette the genes tested are now Seurat's in all 11 cell types, and the genes called atp_val_adj < 0.05agree at Jaccard 0.947–1.000 (median 0.993), where 1.2.0 reached 0.41–0.66. Per cell on the DE tutorial's PBMC 3k clusters, truecell and Seurat call the same 712 genes atp_val_adj < 0.05, with the same top 50 and p-value Spearman 0.9999991 on genes detected above 5 %. Thedeseq2extra needs pydeseq2 0.5.4 or a later 0.5 release, the version this was checked against. - BREAKING:
find_clustersruns Seurat's own modularity optimiser, so a graph gives Seurat's partition. Algorithms 1, 2 and 3 now run a translation of the C++ behindFindClusters(RunModularityClusteringCpp, Waltman and van Eck's ModularityOptimizer):n_start=10random starts drawn from onejava.util.Randomstream, up ton_iter=10iterations each, and the partition with the highest modularity kept, its clusters numbered by size. truecell ran one pass of igraph's multilevel Louvain, which settles in a shallower optimum and a different partition;optimizer="igraph"keeps that pass. On the same graph the labels are now Seurat 5.5.1's, cell for cell: in all 12 runs on PBMC 3k's shared-nearest-neighbour graph (algorithms 1–3 at resolutions 0.4, 0.5, 0.8 and 1.2, singletons included) and on every graph in the tests' reference. It holds at scale, at about 1.2 times Seurat's time: on SNN graphs exported from Seurat, every one of 100,000 cells in 10.3 s against 8.9 s, and of 941,000 in 280 s against 234 s. The defaultresolutionis Seurat's 0.8 rather than 0.5,modularity_fxn,n_startandn_iterare Seurat's arguments, andalgorithm=3, smart local moving, now works. Leiden takesn_itertoo, 10 by default where leidenalg ran until stable, and turns a seed of 0 into 1 with a warning, asRunLeidendoes;n_iterationsis accepted for one more release, with aFutureWarning. Every default partition changes, and every tutorial's clusters with it. Where edge weights tie exactly, Seurat's own partition depends on the processor: built for arm64, its C++ fuses a multiply-add that flips near-tied moves. truecell follows the x86_64 build, as it does forclara; PBMC 3k's graph is not such a case. The DE tutorial now tests clusters of 703 and 480 cells rather than 692 and 515, so its numbers were re-measured. create_fovsputs the images in category order when the FOV labels are categorical. Other labels still give the order of first appearance.from_anndatarelies on this to rebuildobj.imagesin the orderas_anndatarecorded, even when a later image's cells come first.- BREAKING: the imaging loaders build what Seurat's readers build from the same
files.
load_cosmx,load_merscopeandload_xeniumwere read against the source of Seurat 5.5.1'sLoadNanostring,LoadVizgenandLoadXenium, then checked on synthetic bundles in each platform's layout, with the cases those readers treat specially planted in them (tests/test_loaders_vs_seurat.py). load_cosmxnames cells<cell_ID>_<fov>, where it wrote<fov>_<cell_ID>, so one name picked out different cells in the two tools. It drops thecell_ID0 row each FOV carries for transcripts outside any cell, and cells with no counts. On the test bundle 1.2.0 made 12 cells where Seurat makes 8.- All three build one image holding every cell, named by a new
fovargument that defaults to"fov".load_cosmxandload_merscopebuilt one image per FOV, andload_xeniumone named"xenium", so a ported script'sobj.images["fov"]raisedKeyError.fov_column="fov"still builds one image per FOV. load_merscopedrops the features matchingLoadVizgen's case-sensitive^Blank-, where it dropped every name starting with "blank" in any case, and takes cell ids from the leading column of each file, asReadVizgendoes.load_cosmxandload_merscopefind their files byReadNanostring's andReadVizgen's patterns, so a run'sLung5_Rep1_exprMat_file.csvneeds no name.- The centroid radius spans every row of the coordinate table, as
CreateCentroidscomputes it before Seurat keeps the object's cells. load_xeniumfillssegmentation_methodwith"cell"when the cells table has none, asLoadXeniumdoes, and decodes acell_idstored as bytes itself, asReadXeniumdoes, rather than leaving it to pandas.
The loaders still read centroids only; Seurat's also load cell polygons and
transcripts. load_cosmx, load_merscope and Xenium's 2.0+ layout have not been
compared with R on a real run, and the API page now says which loader was checked
on what. pyarrow joins the dev extra, so the tests read cells.parquet.
dim_plotputs each group's label at the median of its cells, as Seurat'sLabelClustersdoes, in each panel when the plot is split. It took the mean, which a few outlying cells pull off the group. Every labelled embedding in the tutorials moves slightly.viz_dim_loadings,image_dim_plotandspatial_dim_plotreturn figures laid out by constrained layout, which keeps room for the legend beside or under the panels at any size. Callingfig.tight_layout()on one replaces that layout, with matplotlib's warning, and leaves no room for the legend.- The performance report is re-measured for this release, on an Apple M5 Pro;
it had stood at truecell 1.0.0 on an M4 Pro. truecell is faster in 45 of 68
like-for-like steps and still loses the standard workflow by 1.2–2.5x, to seeded
UMAP and presto's Wilcoxon. With Seurat's optimiser on both sides, clustering finds
the same number of clusters on all four datasets, at 1.1–1.2x Seurat's time from
8,000 cells up. DESeq2 has a row for the first time, 2.5x faster than Seurat's on
half its memory. The M4 Pro sweep is kept in
results_m4pro/, and the report's two findings from it are labelled as such.
Fixed¶
subset(cells=...)left the object misaligned whenever the cells were not given in the object's own order. The metadata, the assay's cell axis, the reductions and the graphs took the order of the request, while every layer, the identities and the image coordinates kept the object's. Each slot stayed right under its own labels, so nothing looked wrong, but anything that pairs two slots by position paired one cell with another:nCountsat beside a different cell's counts, and Moran's I on a 2,000-cell subset of the Xenium breast section, requested in R's sorted barcode order, was off by up to 0.193 against Seurat. The object now keeps its own cell order whatever ordercellsarrives in, as Seurat'sintersect(colnames(x), cells)does, and that same subset agrees with Seurat to 2.2e-14 over all 313 genes.Assay5.subsetkeeps the assay's order on both axes (subset.StdAssay'sMatchCells(ordered = TRUE)), andfind_spatially_variable_featuresfinds expression columns by the layer's own cell names rather than by position incell_names(). A subset requested in object order was never affected. Found by the Frontiers revision re-running the paper against the 1.2.0 wheel.- Identity levels keep their order through
subset. They were rebuilt from the values, which sorted them: levels10, 2, 1came back1, 10, 2, changing plot order and the orderfind_all_markersvisits clusters in. Levels no retained cell carries are now dropped, asIdents(x, drop = TRUE)drops them. Assay5.subsetandDimReduc.subsetno longer slow quadratically with the number of cells. Both located every cell with alist.indexcall.find_markers(test_use="negbinom")no longer depends on the statsmodels version, and it is nowglm.nb's answer. Seurat'sGLMDETestfitsMASS::glm.nb, which estimates theta and the coefficients by maximum likelihood. truecell called statsmodels'NegativeBinomial. Its BFGS fit collapsed theta or stopped unconverged on some genes, and which genes changed with the numpy, scipy and statsmodels versions. Between statsmodels 0.14.6 and 0.15.0, the p-values of 39 of PBMC 3k's genes moved by more than 2 %, 8 of them by more than a decade, and the DE tutorial's top 50 fell to 49 of Seurat's under 0.15.0. The fit is now truecell's own (truecell/_glm_nb.py): IRLS for the coefficients, alternating with maximum likelihood for theta, stopped asglm.controlstops. Its output is identical to the bit under both versions. Against Seurat on the DE tutorial's clusters:- p-value Spearman on genes detected above 5 % went from 0.9217 to 0.9999991, and
is 0.9999995 on the clusters
find_clustersgives now; - the genes on the other side of
p_val_adj < 0.05went from 48 to none, and none on today's clusters either; - on the genes the two statsmodels versions disagreed on, it matches R's
glm.nbwithin 2e-5.
It is slower: 49 s against 36 s on the DE tutorial's 13,714 genes.
- find_clusters absorbs singletons as Seurat's GroupSingletons does, in two
more details. A singleton that joins a cluster now counts towards that
cluster for the singletons after it, because Seurat looks each cluster's cells
up again, and a tie goes where Seurat's set.seed(1); sample() sends it rather
than to the lowest label. A cell with no edges ties across every cluster, so it
used to land in cluster 0 and now lands where Seurat puts it.
- A find_clusters call that fails part way writes nothing, as
FindClusters does: every resolution's column is built before any is stored.
A failure at a later resolution used to leave the earlier columns on the object.
- Plots draw identities in which some groups are named and others still numbered.
dim_plot, vln_plot, feature_scatter, do_heatmap, ridge_plot and dot_plot
sorted their groups with a key that gave a number for a numbered group and text for a
named one, and Python cannot order the two. Renaming some clusters and not others
raised TypeError in all six, as it did in 1.2.0, and so did split_by on such a
column. Numbered groups now come first, in numeric order, then named ones in
alphabetical order, the order each kind already had on its own. Running the guided
tour's notebook against this release found it: an annotation map written for eight
clusters, applied to the nine that find_clusters now finds, left one numbered.
- The guided-tour notebook follows the clusters this release finds. Its annotation
named 1.2.0's eight clusters, so against the nine find_clusters now finds it left one
numbered, and its prose explained how the dendritic cells go missing at resolution 0.5,
which they no longer do. It now names all nine and asserts that its map covers exactly
the clusters produced. It follows FCER1A across five resolutions to show the 32
dendritic cells holding together from 0.3 to 1.5. It says that rename_idents maps by
name, where it claimed position. Its test-menu, cell-cycle and catalogue passages were
checked against a run on this release, and its labelled plots repel. The notebook is
committed without outputs until it is run in Colab against the release.
- The DE tutorial's roc band no longer fails a run that meets Seurat's
rounding exactly. Seurat rounds myAUC to three decimals, so the two AUCs can
differ by at most 5e-4, but 0.488 - 0.4875 is 0.0005000000000000004, and six of
PBMC 3k's genes sat a few ULPs past the bound. The difference is now rounded at
1e-12 before it is compared.
- from_anndata no longer adds a layer named None. anndata 0.13 lists X among
the layers under that key, so the conversion copied the counts a second time, and
as_anndata then refused the object from_anndata had just built.
- The ImportError from as_anndata and from_anndata names the right extra,
truecell[anndata], where it said seurat-object[anndata].
- The DE tutorial runs against an installed truecell. pbmc3k_de_tutorial.py
imported tutorials.bands without first putting the checkout root on sys.path,
so it worked only from an editable install. Against the 1.2.0 wheel, which is a
reviewer's setup, it raised ModuleNotFoundError. It now sets up the path as the
other tutorials do, and tests/test_tutorial_bootstrap.py reads every tutorial for
the same mistake. A new CI job, wheel-tutorials, runs pbmc3k_tutorial.py and
pbmc3k_de_tutorial.py against the built wheel in a fresh environment, with
tutorials/ copied out of the checkout so that only the wheel can answer
import truecell.
- Per-FOV images from load_cosmx and load_merscope were named 1.0, 2.0, …
whenever the coordinate table had no row for one of the object's cells, as for the
cell_ID 0 rows every CosMx run carries. Looking the cells up filled the gap with
NaN, which turned the integer FOV column into floats. fov_column= names them 1,
2, … now.
- The DE tutorial compares against R's exact values. compare() read avg_log2FC
and p_val from write.csv's 15 significant digits and used the hex-float table
only for p_val_adj, so the fold-change bound it printed, 6.2e-15, measured R's
formatter. Read from the hex tables it is 1.8e-15, one unit in the last place of a
double. Of the other numbers only the all-genes p-value Spearman moves, in the fifth
or sixth decimal place (deseq2 0.999251 to 0.999237), and every band holds.
- The benchmark harness runs on Linux. machine() reads /proc/cpuinfo and
sysconf where there is no sysctl. The Seurat arm runs the Rscript on PATH
rather than /usr/local/bin/Rscript, and the truecell arm the Python running the
harness rather than the checkout's .venv; TRUECELL_BENCH_RSCRIPT and
TRUECELL_BENCH_PYTHON point either elsewhere. bench_seurat.R reads the THP-1
counts through gzip -dc, not macOS's gzcat. Every result file now records the
BLAS and LAPACK each arm linked. run_benchmarks.py report no longer stops at
tutorial_scripts.json, and the Xenium bench takes its 2,000-cell subset in object
order: through 1.2.0's subset, its sorted() cell names gave Moran's I values for
the wrong cells, though not wrong timings.
- Stale documentation. The README, the docs home and installation pages, the main
skill and the API map named 0.9.0 as the current release, and five vignettes named the
version they were first written against; they now point to PyPI and the changelog.
Numbers that had moved are re-measured in the uv sync --locked environment: PBMC 3k's
PCA matched |r| is 0.9986 (min 0.9905; the docs said 0.9988 and 0.9946), its clusters
9 against 9 at ARI 0.928 with identical marker sets on three clusters, and the
spatial-statistics tutorial matches 39 of 39 anchors, not 38.
- The API map's signatures are checked against the code. Five entries in
skills/truecell/reference/api-map.md had fallen behind it (run_tsne, glm_pca,
find_clusters, aggregate_expression and the loaders).
tests/test_api_map_signatures.py now compares every signature there with the
function's, parameter for parameter and default for default.
- The dimensional-reduction tutorial's correlations stay within [-1, 1]. Its
_corr_matrix took dot products of standardised columns, which rounding can put one
ULP past 1. On an Apple M5 Pro under macOS 27 a run compared with itself read
1.0000000000000002, failing a band bounded at 1 in tests/test_dimreduc_tutorial.py,
on main too. It now clips as np.corrcoef does.
- Legends no longer cover the data. viz_dim_loadings drew its legend over the
positive loadings' bars; it is now one legend under the panels. image_dim_plot and
spatial_dim_plot kept a fixed 12% strip for theirs, and a legend of cell-type names
is wider, so it lay over the tissue: over 4,151 cells in the Xenium tutorial's
cell-type map.
- Tutorial figures that failed the layout check. At the size the tutorials show
them, in 12 labelled UMAPs the labels collided or a label's box covered most of a
small cluster; they now repel, and the pbmc3k annotated UMAP's "Platelet" label no
longer covers all 14 platelets. The pbmc3k variable-feature plot's gene names
overlapped, as did the sketching tutorial's leverage-rarity names, and the kNN-degree
histogram's legend covered its bar. Six more failed only at the 0.55 the manuscript
prints them at: the integration scoreboard, whose legend sat on its bars; the anchor
agreement's titles and counts; the leverage titles; the DE log2FC and concordance
legends; and the Moran's I legend and title. At that size every figure the manuscript
takes from the tutorials now passes, except the pbmc3k marker heatmap, whose 45 gene
names can not fit its height.
- Documentation that had fallen behind its own comparisons. Before this release all
eighteen tutorials were run on both sides, Python first, then R, then the report, and
every page that quotes a result was read against it.
- The tutorial index's rows for PBMC 8k, CITE-seq, integration, reference mapping,
dimensional reduction and sketching still quoted the numbers from before this
release's changes; the README, the docs home page and the skills carried the same.
- The sketching vignette's numbers were still those of its first run, in July:
leverage tracks rarity at −0.918 in both tools, not −0.929, and projected labels
agree per cell 94.6 %, not 94.9 %.
- The CITE-seq, reference-mapping, dimensional-reduction and DE pages are re-measured
from the same run. The DE tables gain poisson's rows, which they had left out.
- The integration skill still said truecell's clusters differ from Seurat's by design,
which stopped being true when find_clusters became Seurat's optimiser, and the
quickstart's marker excerpt listed the platelet genes under cluster 7, which is now
the dendritic cells.
- The R figures that changed on the re-run are committed. R reproduced every one of
them byte for byte on a second run.
- The PBMC 3k tutorial names its CD8 T and NK clusters again. Its
_assign_cell_types handed cell types out cluster by cluster, each cluster taking
the best-scoring type still free. With Seurat's optimiser, both NK genes reach the
CD8 T cluster's top 50 markers, so that cluster took NK, two panel genes to CD8 T's
one, and the NK cluster was left "Unknown". The script printed both wrongly, and the
report's label agreement with Seurat fell to 82.0 % while the clusters agreed on
97.1 % of cells. Each cell type now names at most one cluster, in the assignment
that matches the most panel genes overall; ties go to the earlier cluster, then the
earlier panel. pbmc3k_verify.R labels Seurat's clusters the same way. Label
agreement is 97.1 %, all nine matched clusters named alike (1.2.0: 95.5 %, with no
dendritic-cell cluster). The published tutorial names the clusters from a fixed
map, as Seurat's does, so it was never affected. Found by running every tutorial
against 1.2.0 on the same machine. With this fix, 11 reports are identical to
1.2.0's, and PBMC 3k, DE and Mixscape agree with Seurat better. PBMC 8k and
integration agree better overall with some numbers lower, and the cell-cycle
phases move onto Seurat's split while per-cell agreement falls. CITE-seq loses a
cluster against Seurat on the Mac (15 against 16, labels 98.94 % to 98.72 %) and
matches it on Linux x86-64 (15 against 15, 99.91 %). Each of those is explained
in its vignette.
- The Mixscape and cell-cycle R scripts ran only on macOS. Both read THP-1's
counts through gzcat, which a Linux shell does not have. They now use
gzip -dc, as the benchmark's R arm already did, and
tests/test_tutorial_r_portability.py checks every R script under tutorials/
for a decompressor only one platform has. Nothing they compute changes.
- Docs and skills read against this release.
- The DE skill said p_val_adj corrects over the genes tested in a call.
truecell, like Seurat, multiplies by every feature in the assay, so neither
logfc_threshold nor features= changes it. The same skill gave roc's old
filter and not the new row order.
- The markers API page said pct.1 and pct.2 could differ from Seurat's by up
to 5e-4. Rounded as Seurat rounds them, they now match exactly.
- The integration skill still gave RPCA's batch mixing from before find_clusters
ran Seurat's optimiser (0.991); it is 0.917, as in Seurat.
- The installation page listed negbinom under the analysis extra. Its GLM is
truecell's own now, so it runs on the core install.
- The README, the fidelity page and the tutorial index installed from source with
uv pip install -e, which resolves fresh; they now use uv sync --locked, as
the installation page does. The README's test count (955) and the dev skill's
(972, with ruff and mypy "clean") were months old; neither linter is clean.
- The object-model reference now describes subset's cell order and identity
levels, the plotting skill the constrained-layout legends, and the installation
page the two R packages whose presence changes Seurat's output (presto and
glmGamPoi).
- The DE tutorial's fold-change figure was titled "13,712 genes, 1,172 shared
cells", typed in before the feature renaming and the new clusters. The title is
now counted from the data: 13,714 genes and 1,183 cells.
- find_markers(test_use="deseq2") runs in one process. pydeseq2 starts a joblib
worker per CPU core, once for its data set and again for its test, and the workers
outlive the call. Re-measuring the performance report found them taking the process
tree from under 1 GB to 5.2 GB on PBMC 3k's DE bench to save 1.5 s, and doubling
the out-of-core tutorial's peak to 11.5 GB. DESeq2 runs in one R process and the
answer does not depend on the worker count, so truecell now gives pydeseq2 one. The
bench's call takes 4.0 s where it took 2.5 s, still 2.5x faster than Seurat's, and
peaks at 1.2 GB. tests/test_deseq2_seurat_parity.py fails if any of pydeseq2's
parallel sections asks for more than one job.
[1.2.0] - 2026-08-10¶
Four of Seurat's functions that truecell did not have, and a sweep for arguments that only looked like they worked.
The features close the last gaps in the ported API that were not blocked on
something external: find_markers now offers all nine of Seurat's tests,
prep_sct_find_markers makes differential expression valid across a merge of
separately-SCTransformed objects, diet_truecell slims an object for saving,
and find_neighbors(return_neighbor=True) finally produces the Neighbor
objects the class was written for.
The fixes come from one finding. find_neighbors had an nn_name argument that
was accepted and never read — and Seurat has no such argument, so it was a name
invented for a feature nobody had implemented. Rather than leave that to luck, an
AST sweep of every module (tools/find_dead_args.py, committed) looked for the
rest of the class: parameters never loaded anywhere in their own function body.
34 hits, 22 legitimate, 3 deliberate and documented as such, 9 real. Public
dead arguments are now zero.
Two breaking changes, both removals of things that never worked, so no call that currently does anything changes behaviour — see Removed and Changed.
Added¶
find_markers(test_use="poisson")— the ninth and last of Seurat's DE tests, and the other half of itsGLMDETest: a Poisson GLM Wald test on the counts layer, honouringlatent_vars(Seurat'sDEmethods_latent()is exactlynegbinom,poisson,MAST,LR). Reachesfind_all_markersandfind_conserved_markersunchanged, since both passtest_usethrough.
Verified against Seurat 5.5.1 on PBMC 3k clusters 0 vs 1: 50/50 on the top
50 genes, avg_log2FC to 6.2e-15, p-value Spearman 0.9999984 on
genes detected above 5 %, and zero disagreements on which genes clear
p_val_adj < 0.05.
Two things worth knowing before using it. It is anti-conservative on
scRNA-seq by construction — fixing the dispersion at 1 asserts
Var = mean, which UMI counts violate, so standard errors come out too small
and p-values too extreme; negbinom estimates the dispersion and is the
better-calibrated of the two. And truecell returns genes Seurat drops:
GLMDETest deletes any gene detected in fewer than min.cells (3) cells in
both groups, 2,248 of them on this contrast, where truecell returns
p_val = 1 so the gene set stays the same across every test_use.
prep_sct_find_markers— Seurat'sPrepSCTFindMarkers.sctransformcorrects each object's counts to that object's median sequencing depth, so merging two SCTransformed objects leaves the two halves of the SCTcountslayer on different scales and a fold change across the merge partly measures how deeply each batch happened to be sequenced. This re-corrects every cell to the minimum median UMI across the models. Run it once after the merge and before anyfind_markerscall on the SCT assay.
Verified against Seurat 5.5.1: given R's own fitted models, truecell
reproduces PrepSCTFindMarkers exactly — 0 of 13,953,800 entries differ
on a 9,967 x 1,400 matrix. R's parameters are injected rather than refitted
because truecell's SCTransform is deliberately not bit-identical to R's, so an
end-to-end run would measure the model fit instead of the re-correction.
Supporting change: sctransform now records the fitted model on the SCT assay
(misc["SCTModel.list"] — per-gene theta/(Intercept)/log_umi, per-cell
umi, the median UMI, and the source counts assay), mirroring Seurat's
SCTModel.list.
diet_truecell— Seurat'sDietSeurat. Strips an object down to chosen assays, layers, features, reductions and graphs, for saving, sharing, or holding several at once. Returns a new object and leaves the input alone; the layers that survive are shared rather than copied, so it frees memory rather than briefly doubling it.
diet_truecell(obj) with no arguments deletes every reduction and every
graph. dimreducs and graphs are keep-lists, and an unset keep-list keeps
nothing. This is Seurat's behaviour, confirmed against 5.5.1 rather than
assumed — a pbmc3k object with pca, umap and two graphs comes back with
zero of each and all three layers untouched. Name what you want kept:
diet_truecell(obj, layers="counts", dimreducs="pca").
Verified against Seurat 5.5.1 across eight configurations (default, per-layer, per-reduction, per-graph, feature subset, and combinations) — layers, reductions, graphs, feature count, cell count, assay list and the counts sum match on every one. Both of R's aborts are ported: an unknown assay, and removing the assay that is currently the default.
find_neighbors(return_neighbor=True)— Seurat'sreturn.neighbor. Stores the raw KNN result, indices and distances, as aNeighborinseurat.neighborsinstead of building graphs. The distances were already being computed on every call and discarded.compute_snnis exposed alongside it, defaulting tonot return_neighboras in R, which warns and builds no SNN if you ask for both.
Verified against Seurat 5.5.1 (nn.method = "rann", the exact search, since
the default annoy is approximate): on pbmc3k, fed R's own PCA embedding so
the comparison isolates the neighbour search, all 54,000 neighbour indices
match and the distances agree to 1.1e-13.
Two details worth knowing. The Neighbor is stored under "<assay>.nn" — a
dot, where the graphs use an underscore (RNA.nn against RNA_nn /
RNA_snn); that is Seurat's naming. And the stored indices are 0-based,
where R's Indices() are 1-based.
-
A guided-tour notebook for Colab —
tutorials/truecell_guided_tour.ipynb, generated bytutorials/build_guided_tour.pyand executed end to end before committing, so every output in it is real. The tutorials index points at it as the entry point for someone meeting the package for the first time. -
tools/find_dead_args.py— the AST sweep described above, kept so the check is repeatable rather than a one-off. It reports parameters never loaded in their own function body, nested scopes included; every hit still needs triage, since dispatch adapters and protocol methods are legitimately unused.
Changed¶
- BREAKING:
Truecell.reorder_identnow takes Seurat's arguments. It wasreorder_ident(ident, order)withidentnever read; it is nowreorder_ident(var, reverse=False, afxn=np.mean), R'sReorderIdent— summarisevarwithin each identity and sort the levels by it. Verified against Seurat 5.5.1 on a fixture whose per-ident means are A=3, B=2, C=4, D=1: both giveD, B, A, C.
One deliberate divergence: R's reverse does nothing. It transforms the
values of an already-sorted named vector and reads names() off the result,
which leaves the order untouched — 5.5.1 returns D, B, A, C either way.
Here it genuinely reverses, because shipping a third argument that silently
does nothing is the defect this release is about. R's reorder.numeric is
not ported: on 5.5.1 it warns Cannot find cells provided and leaves the
levels unchanged, so there is no working behaviour to match.
Removed¶
-
BREAKING:
find_neighbors(nn_name=)is gone. It was accepted and never read — no code path in truecell had ever populatedseurat.neighbors, and Seurat has nonn.nameargument to be faithful to. It was not given retroactive meaning because every plausible reading is a trap: making it implyreturn_neighbor=Truewould have silently stopped storing the graphs for anyone already passing it. ATypeErroris the honest outcome. Migration:nn_name="X"→return_neighbor=True, graph_name="X". -
stitch_matrixis deleted. It had no callers, no tests, and ignored both of itsrow_names/col_namesarguments — the body justhstacked the blocks, where a realStitchMatrixaligns by those names. It was registered as a generic, so the module-only generic count indocs/api/index.mddrops from 66 to 65.
Fixed¶
-
Assay5.mergedropped every SCT model but the first.miscwas carried over from the first assay alone, so merging two SCTransformed objects produced an assay that looked complete, held two batches corrected to two different depths, and kept no record that there had ever been more than one model — leaving nothing forprep_sct_find_markersto act on. Model lists are now unioned and renumberedmodel1..modelNin merge order, with cell names carrying the sameadd_cell_idsprefix the layers get. Othermisckeys keep the existing first-wins behaviour. -
plot_perturb_score(target_gene_class=)was ignored. It is documented as the metadata column holding each cell's guide class, but the code tookscores.columns[1]positionally. R indexes that frame by name (prtb_score[, target.gene.class]). The two agree at the defaults, which is why it went unnoticed; they diverge as soon asrun_mixscape(labels=...)names the column something else. Now name-based, falling back to position (where R simply fails) since the stored frame is always["pvec", <labels>]. -
calc_n(margin=)was ignored — it always summed down columns, somargin=1silently returned per-cell numbers where per-feature were asked for. Both margins now work, on dense, sparse and on-disk matrices; the lazy path walks the store in cell blocks rather than densifying it. -
DimReduc.features(projected=)was ignored, and reachable that way through the publicgenerics.featurestoo. It returned the unprojected names whichever slot you asked for — so on a reduction with no projected loadings it announced N features for a zero-row matrix. It now follows the matrix, as R'sFeatures.DimReducdoes (rownames(Loadings(projected = projected)), empty when there is none).DimReducgained afeature_names_projectedaxis, sinceProjectDimscores every gene in the assay while the reduction itself covers only the features it was computed on — the two lists genuinely differ. -
do_heatmapemitted atight_layoutwarning on every call. The colour-bar row usesgridspec_kw={"hspace": ...}, which matplotlib treats as incompatible withtight_layout()— it warns and skips the adjustment entirely. The call was a no-op (verified: identical axes geometry and a byte-identical PNG with or without it), so it was removed rather than worked around. -
Documentation:
negbinomwas still described as a likelihood-ratio test in the DE skill's test table, which it has not been since the T-de tutorial moved it onto Seurat's ML-dispersion Wald statistic. Corrected.
1.1.0 - 2026-08-08¶
Ten weeks of plotting work, a domain-expert review, and the release-readiness run that found the defect below. The API additions are backwards compatible; the fixes change output, which is the point of them.
If you are on 1.0.0, the reasons to move are the corrections, not the
features: the categorical palette could give two clusters the same colour,
vln_plot drew the wrong violin three ways, aggregate_expression left raw
sums where Seurat log-normalizes, and _get_expression_matrix returned the
wrong layer with mislabelled features.
Added¶
- CI runs the PBMC 3k tutorials against real data. A new
tutorialsjob caches the 28 MB dataset and runs nine smoke tests against it — four tutorial scripts end to end (guided, SCTransform, dim-reduc, objects), plus the marker table, the object-model round trip, the dim-reduc extras and both cell-type-map guards. Until nowtest_tutorial_smoke.pyran nowhere but a developer's machine, and the entry above is what that cost.
lazy_bpcells and pbmc3k_de are deliberately not in it. Both pass and
both have their data cached; they were 65% of the runtime (136s and 102s of
364s locally, and the first CI run measured 16m33s for all eleven), and
neither draws a labelled figure. That is a cost trim rather than a coverage
judgement, and it is the weaker half of this change: the full opt-in suite is
the only thing that runs them, which is why the pre-release run stays a
requirement.
Two details are the point rather than decoration. A skip fails the job:
every selected test needs only the cached dataset, so a skip can only mean a
broken cache, a misspelt TRUECELL_TUTORIAL_SMOKE, or a new test wanting a
dataset this job does not fetch — all of which would otherwise produce a green
tick for a job that verified nothing. And the selection deselects the eight
uncached datasets instead of selecting pbmc3k by name: an allow-list reads
better but would let a pbmc3k test added later under another name silently
never run, which is precisely the failure being fixed.
The other datasets total ~200 MB and stay developer-run, so
TRUECELL_TUTORIAL_SMOKE=1 pytest tests/test_tutorial_smoke.py before a
release is still the rule, not a formality.
Fixed¶
- The annotated PBMC 3k UMAP captioned the platelet cluster "DC". Shipped in
1.0.0.
tutorials/generate_plots.pycarried Seurat's ninenew.cluster.idswhile this pipeline resolves eight — DC merges into CD14+ Mono at resolution 0.5 — andrename_identsis positional, so every label from position 7 on slid by one. The 14-cell platelet cluster (PPBP 5.85, FCER1A 0.00) was labelled DC, and "Platelet" was never applied to anything.
What makes this one worth reading twice: the rest of the repository already
had it right. pbmc3k_tutorial.md prints the corrected eight-entry map
directly beside the figure drawn from the stale one, and tutorials/README.md
states that all 32 DC cells land in CD14+ Mono. One file lagged, and it was
the file that draws the pictures. Seven figures are regenerated here — the
label set also changes the alphabetical palette assignment, so the two
cluster-coloured panels move too. 10_marker_heatmap.png correctly does not:
it restores cluster numbers before drawing.
Two prose claims counted the same nine and are corrected here: the caption
directly beneath the figure said "the biological result — 9 identical cell
types — is the same", and tutorials/README.md said the tutorial annotates
nine. Both contradicted Step 11 and the fidelity table on the same page. The
caption also attributed the differing cluster numbering to Louvain being
"non-deterministic by cluster ID"; both tools in fact number clusters by
descending size (R: 711, 478, 471, 344, 270, 164, 154, 32, 14 — truecell:
692, 515, 458, 344, 301, 159, 155, 14), so the keys differ because the sizes
do. R's cluster 7 is the 32-cell DC and its 8 the 14-cell platelets; truecell's
7 is those same platelets, which is the whole defect in one line.
The guard test existed and was correct. It never ran: test_tutorial_smoke.py
is opt-in and excluded from CI by design, so nothing failed. It now also
asserts that the map's keys are exactly the cluster ids produced, which
catches a length mismatch directly instead of inferring it from a marker
landing in the wrong place — the old assertion reported this defect as
"FCER1A is highest in CD14+ Mono", which describes the clustering rather than
the labelling and reads like a false alarm.
- The PBMC 3k guided tutorial documented a clustering ARI of 0.938 while
measuring 0.899, across six files (
tutorials/README.md×2,pbmc3k_tutorial.md×2,docs/fidelity.md,docs/quickstart.md). The associated concordance figures were stale too — 2,554/2,638 cells and 0.968, against a measured 2,519 and 0.955.
The likely cause is the graph fixes in #67–#71, which moved cells between
clusters — the same drift the DE tutorial's deseq2 top50 band caught at the
time (25 → 22). This tutorial had no band on its headline number, so it
went stale in six documents instead of failing once. Every swept resolution
now carries a declared band, checked by --report.
Verified this predates the change: R's vector-form FindClusters produces a
partition identical to the single 0.5 call, and the tutorial's four handoff
outputs are byte-identical to what main produces.
Changed¶
- The guided clustering tutorial scans four resolutions instead of one.
Choosing a resolution means running a few and comparing them, so a fidelity
claim pinned to a single setting says less than it appears to. Both sides now
use the vector idiom —
find_clusters(pbmc, resolution=[0.4, 0.8, 1.2, 0.5])andFindClusters(pbmc, resolution = c(...))— and every resolution is scored against R:
| resolution | truecell | Seurat | ARI |
|---|---|---|---|
| 0.4 | 9 | 9 | 0.8958 |
| 0.5 | 8 | 9 | 0.8987 |
| 0.8 | 11 | 11 | 0.8264 |
| 1.2 | 12 | 12 | 0.7995 |
Two readings. The cluster count matches exactly at 0.4, 0.8 and 1.2, so the 8-vs-9 split this tutorial describes is specific to resolution 0.5 rather than a standing property of the port. And agreement falls as resolution rises, which is expected: finer partitions put more boundaries in play.
0.5 is given last deliberately, since Seurat leaves the object on the last resolution in the sequence and every step below the clustering call is written against it. The four handoff outputs are byte-identical to before, and a test pins the ordering, because reordering the list would silently re-point UMAP, the markers, the annotation and the handoff without raising.
add_module_scoreis now verified against Seurat as an equality, not a correlation. Every prior comparison was correlation-based and structurally had to be:AddModuleScorepicks control genes withsample(), R's RNG is not NumPy's, so the two cannot produce the same number. Pearson 0.9995 is a real result, but it is the RNG-limited one — it cannot separate a faithful port from one whose binning is subtly wrong, because binning error and sampling noise land in the same residual.
nbin=1 puts every gene in one bin and ctrl = pool size draws all of it;
sample(n, n) is a permutation, so the control set is forced and only its
summation order is free. In that regime the two tools agree to
6.66e-15 across 20,729 cells — floating-point associativity over 18,649
genes, against 1.8e-01 between two R seeds at ctrl = 8. The binning, control
selection and mean subtraction are exact.
Also adds the first coverage of the multi-program path and of any settings
other than the defaults: three programs in one call at nbin=12, ctrl=40
(Pearson 0.9972 / 0.9988 / 0.9986), with a guard against column
transposition — programs are identified by position alone, so a swap would
leave every correlation high and every label wrong.
Six mutants, all caught. Two survived the first pass, both because the new tests fabricated the R side from Python's own column and so could not detect a change in how Python computed it — the same defect shape this repo has hit before. Closed by asserting the pipeline's settings produce a column that differs from a default-settings recomputation.
Fixed¶
- The R verify scripts now serialize anchors at full float64 precision.
Four scripts wrote their JSON references at
digits = NA,12or15, each of which truncates. Measured against R Seurat 5.5.1 on a 2,000-value spread of realistic magnitudes:NAand12lose about half the values,15loses ~12%, and17and22are exact. All four now use22, matchingvisium_verify.R, which had already worked this out.
Two things about jsonlite's digits are worth stating because both are
counter-intuitive and both were assumed wrong somewhere in this repo:
it counts decimal places, not significant digits — so whether a setting is
lossy depends on the magnitude of the value — and NA is not "max
precision"; it round-trips fewer doubles than an explicit 17.
What actually moved: the out-of-core reference gained precision on 203 of
309 values (1.63587331771851 → 1.6358733177185059), Xenium spatial on
37 of 60 (24.073884121202 → 24.073884121202223), and Xenium SVF on 4
of 81 Moran's I values. The object-model reference moved on 0 of 160,
because it already rounds every float to 6 dp on both sides — NA was losing
nothing there, and is changed only so the next unrounded anchor does not
inherit a trap.
No comparison outcome changed: 91/91 object-model anchors, all Xenium deterministic anchors, and every out-of-core and SVF anchor still match, now against R's true values rather than truncated ones.
-
The out-of-core report no longer cuts numbers mid-digit. Once the R reference carried full precision, a hard 22-character slice rendered
1.0153110547861388e-06as1.0153110547861388e-0, dropping the exponent's last digit — a reader would take 1e-06 for 1e-0. Values are now shown whole or elided with an ellipsis, and a 1-element list from the R side is unwrapped so the number gets the column instead of the brackets. -
Every tutorial CSV read now round-trips float64. pandas' default CSV reader is not correctly rounded — it misparses about a third of random doubles by an ULP.
to_csvwas never at fault; it already writes the shortest round-trippable form. So a tutorial that wrote a value, handed it to R, read both back and reported "these agree exactly" was partly measuring the parser.
37 call sites across 16 tutorials now pass float_precision="round_trip".
Three reads are exempt with stated reasons (cell labels, a row count, and the
DE hex reader, which parses via float.fromhex).
Six reported figures moved, all at the ULP level and most of them downward —
PBMC 8k's percent.mt 5.773e-15 → 5.329e-15, PBMC 3k's VST mean relative
difference 1.548e-14 → 4.973e-15, SCTransform's detection_rate 5.6e-16 →
5.0e-16. No declared band moved and all nine --report runs still exit
zero. The affected vignettes carry the measured values.
tests/test_tutorial_csv_precision.py pins the convention so the next
pd.read_csv cannot quietly reintroduce it, and checks that the three
exemptions still match something rather than silently widening the lint.
Not fixed here, because it is not lintable from Python: R's write.csv
renders 15 significant digits and raising it does not help — R's own
sprintf("%.17g") is not correctly rounded either. Where bit-identity is the
actual question the R script writes a C99 hex-float side table, as
pbmc3k_de_verify.R does.
-
Corrected a marker-agreement bound in the PBMC 3k docs. They stated
avg_log2FCagreement "to 4.9e-15" for both clusters whose cells match exactly. That covers the 151-gene cluster (4.88e-15); the 242-gene one is 4.62e-14, an order of magnitude larger. Both figures are now given. This predates the reader fix — the bound was simply the smaller of the two. -
aggregate_expression(return_object=True)left raw sums in thedatalayer. Seurat'sAggregateExpression(return.seurat = TRUE)runsNormalizeDataover the pseudobulk, so itsdataholdslog1p(sums / colSums × 10000). Truecell copied the sums across unchanged, which every downstream function reading that layer would have taken for normalized expression — library-size confounded and off by orders of magnitude (14 where Seurat has 6.98).
normalization_method and scale_factor now match Seurat's arguments, with
normalization_method=None to opt out.
This is the one place the two group-summary functions genuinely diverge:
average_expression writes a plain log1p of the means with no library-size
step, and that was already right. Both verified against Seurat 5.5.1.
Found by pinning aggregate_expression against R for the first time. Its nine
existing tests all compared Python to a Python re-derivation of the same
formula — the shape of coverage that cannot catch a convention mismatch, and
the reason the CLR defect once survived its own unit test.
_get_expression_matrixreturned the wrong layer, and mislabelled the right one. Two defects in one function, both reachable fromfind_markers(layer=...),aggregate_expressionandaverage_expression.
The Assay5 layer dict is keyed scale.data; the Python argument is
scale_data. Only the dotted spelling was matched, so the underscore form
missed the dict and fell through to the data fallback — a matrix of the
right shape, no warning, and normalized values where scaled ones were asked
for.
The dotted spelling then hit the second defect: the matrix came back paired
with the assay's full feature list. scale.data holds only the scaled
subset (scale_data() defaults to the variable features, as R's ScaleData
does), so a 10-row matrix was labelled with 30 names and every row read as a
different gene. This is the defect fixed in reduction.py under #66; the same
one survived here. Each layer is now labelled with its own features.
reduction.py, and therefore PCA, was never affected — it has its own
accessor.
Added¶
average_expression, mirroring Seurat'sAverageExpression. Seurat ships two group-summary functions and Truecell had onlyaggregate_expression. They are not two scalings of one thing:AggregateExpressionsums raw counts, whileAverageExpressionaverages the back-transformeddatalayer —mean(expm1(x)), notmean(x)and notexpm1(mean(x)). On a small Poisson object the first gene reads 332.84 under one and 3.17 under the other.
The back-transform applies to the data layer alone; counts and
scale.data are not log-normalized and are averaged as they stand. All three
layers, features=, multi-column group_by and return_object were pinned
against Seurat 5.5.1 — worst absolute difference 6.8e-13 on values of order
300. return_object leaves the averages in counts and writes log1p of
them to data, as Seurat's return.seurat = TRUE does, rather than
re-normalizing a matrix that is already an average.
The expm1 transform is shared with find_markers' fold-change path rather
than copied, since Seurat's fold change is the same back-transform and a
divergence between the two would be undetectable downstream.
find_clustersaccepts several resolutions, and writes the per-resolution column Seurat writes.FindClusters(obj, resolution = c(0.4, 0.8, 1.2))is the standard idiom for choosing a resolution — run a few, compare, then pick — and it had no Truecell equivalent.find_clusters(obj, resolution=[0.4, 0.8, 1.2])now runs each in turn.
The larger fix is underneath it and applies at the default settings: Truecell
wrote only seurat_clusters, never the {graph}_res.{resolution} column,
so a ported script reading obj[["RNA_snn_res.0.5"]] raised KeyError. That
column is now written for a single resolution too.
Three conventions were pinned against Seurat 5.5.1 rather than assumed, and
one of them is a trap. The column label is R's number formatting, not
Python's: str(1.0) is "1.0" where R's as.character(1.0) is "1", so a
naive implementation names the column RNA_snn_res.1.0 and the ported script
still fails. _res_label reproduces R's rule — render fixed and scientific,
keep the shorter, ties to fixed — and is checked against R on 21 values from
1e-7 to 1234567. The object is left on the last resolution given, not the
largest (resolution=[1.2, 0.8, 0.4] ends on 0.4). And each resolution is
clustered from the same seed rather than a running stream, so a partition does
not depend on what preceded it or on the order asked for.
cluster_name is supported, matching Seurat's cluster.name.
Performance¶
find_markersfilters before it densifies. It built a dense (all genes × cells) float64 array per group and only then computed themin_pctandlogfc_thresholdmasks that reduce it to the genes actually tested. Both masks are computable on the sparse matrix —expm1(0) == 0, so the fold-change transform preserves the sparsity pattern and applies to the stored values alone — so they now run first and only the survivors are densified. On PBMC 3k that is ~1.6k rows of 13.7k.
find_all_markers calls this once per cluster, so the saving grows with
cluster count as well as with n:
| dataset (clusters) | time | peak memory |
|---|---|---|
| ifnb (15) | 25.37s → 8.03s | 9420 MB → 3580 MB |
| pbmc8k (9) | 12.71s → 6.32s | 9253 MB → 2406 MB |
| thp1 (7) | 45.52s → 23.87s | 10620 MB → 9428 MB |
| pbmc3k (8) | 2.36s → 1.54s |
The marker tables are byte-identical before and after across all eight tests on PBMC 3k, and all 76 step anchors match across the benchmark suite.
- A cross-language benchmark suite —
tutorials/benchmark/, reported inPERFORMANCE.md. The tutorials established that Truecell and Seurat agree; nothing measured what each answer costs. Eight benches — the standard workflow on four datasets from 2.7k to 20.7k cells, SCTransform, integration, the DE suite, Moran's I and a pure-BLAS control — mirrored step for step in both languages.
Time is measured inside each process and memory from outside: R's gc() and
Python's tracemalloc measure different things and neither sees the other's
allocator, so the parent samples the child's process-tree RSS every 50 ms.
Every step reports an anchor, so the report can show both arms did the same
work before comparing how long they took — which is why the Truecell arm runs
first and the R arm adopts its cluster assignment. Without that the two sides
cluster differently and find_all_markers would be timing 9 one-vs-rest tests
against 12.
Truecell is faster in 50 of 68 step comparisons and slower in 17. Read the
report rather than that ratio: it loses the standard end-to-end workflow by
1.4×, and the machine, the BLAS both stacks link, and the presence of presto
are all load-bearing — R linked against Accelerate/vecLib is a different
measurement from the reference BLAS it ships with.
Added¶
split_byondim_plot,feature_plotandvln_plot. Seurat uses three different mechanisms for this, which is the part worth getting right; each was probed against Seurat 5.5.1 rather than assumed.
DimPlot uses facet_wrap: one panel per level holding only that level's
cells, with scales = "fixed". Both the axis limits and the group colours are
therefore computed across all cells and shared, because panels are only
comparable if a position and a colour mean the same thing in each.
FeaturePlot builds a features x levels grid. The colour scale is computed per
feature over all cells and shared along the row — per-panel scales would make
two very different levels look alike, each filling its own range.
VlnPlot does not facet at all. split.plot = FALSE is its default, and it
says so itself: "Separate violin plots are now plotted side-by-side". So the
levels are dodged within each group's x position and coloured by level,
with the group carried by position. The offsets match ggplot's dodge geometry.
Every existing figure is byte-identical; the feature is purely additive.
Fixed¶
vln_plotdrew the wrong violin, three ways. All three were checked against R 4.6.1 / Seurat 5.5.1 running locally, not against recollection of what Seurat does.
The bandwidth was 2.3x too wide. The density used scipy's "scott" rule,
which scales the sample standard deviation. R's stats::density — and so
geom_violin, and so Seurat — uses bw.nrd0, which takes
min(sd, IQR/1.34). Expression is zero-inflated, so the IQR term is much the
smaller, and measured against R on zero-inflated draws scipy's bandwidth came
out 2.0-2.5x wider. The effect is over-smoothing that flattens the spike at
zero, which is the shape of the distribution. _bw_nrd0 now reproduces R to
5e-13 across seven cases including both zero-spread fallback branches.
(bw.nrd0 divides the IQR by 1.34. The neighbouring rule bw.nrd, which
R also ships, uses 1.349 — taking that one is a silent 0.67% error wherever
the IQR term wins. The first version of this fix had exactly that bug, caught
by diffing against R.)
The violin was not trimmed. geom_violin(trim = TRUE) limits the density
to the observed range; an untrimmed gaussian KDE tails off past it, so every
violin extended below zero where expression cannot go.
Points were off by default. pt_size defaulted to 0. Seurat's VlnPlot
passes pt.size = NULL, which ExIPlot resolves through AutoPointSize —
min(1583/n, 1) — so points are shown and shrink as the cell count grows.
pt_size=None is now the default and follows that rule; pt_size=0 still
omits them.
The violin outline now matches geom_violin(scale = "width", trim = TRUE)'s
own computed polygon to 0.2% of full width, with the support equal to the data
range exactly. Nine tutorial figures are regenerated; they are the nine drawn
by the six generators that call vln_plot, and no others moved.
Two additions fall out of the rewrite: violin_width sets the width of a full
violin, and jitter_seed (default 0) makes the point jitter reproducible so
a committed figure redraws identically.
Still deliberately different from Seurat: the median bar. geom_violin draws
none, and this keeps drawing one.
Added¶
ridge_plotwarns when an explicitfigsizeis too small for the group labels, instead of silently returning a figure with one panel's labels printed across its neighbour. Ridgeline labels sit outside the axes, so a panel is wider than its axes by the length of the longest label; when the canvas cannot fit that, matplotlib overlaps the panels rather than erroring.
The warning names the computed default, which a 162-configuration sweep — up
to 12 groups, labels as long as "Haematopoietic stem cell", every ncol —
found collision-free, and a test asserts the suggested size actually resolves
the collision. Recommending it beats scaling the caller's size by some
function of the overlap, which can still collide.
No layout engine fixes the underlying case: constrained_layout overlaps by
slightly more here and reports "axes sizes collapsed to zero", because the
space genuinely does not exist. The default path is unaffected and pays no
cost — the check only runs when figsize was passed.
Fixed¶
- The categorical palette repeated colours, so two clusters could render
identically.
_PALETTE_36was named for 36 entries but held only 30 distinct ones —#A3A500sat at both index 14 and index 29, and five more colours were doubled in the last five slots._palettesliced that list for anyn <= 36and only fell through to atab20ramp above it, so an object with 30 to 36 groups drew two of them in exactly the same colour, silently. That is a wrong plot, not an aesthetic one: nothing on the figure said which cluster was which.
The list is gone. hue_pal(n) now computes the ramp the way ggplot does —
n evenly spaced HCL hues at c=100, l=65, the polarLUV-to-sRGB conversion
written out so R's per-channel fixup = TRUE clamp is reproduced rather than
approximated. It cannot run out and it cannot repeat.
It is also more faithful, not just safer. ggplot spreads the hue circle
across however many groups there are, so the colours for 9 groups are not the
colours for 8 plus one more. The old list had been built by concatenating
several such runs, which is where the duplicates came from — and it therefore
matched Seurat only at n=8. hue_pal matches at every n, verified exact
against R for n = 1-6, 8 and 9.
This changes existing figures. Any plot coloured by group where the group count is not 8 gets different (correct) colours. Numbers, ordering and layout are untouched.
- Only
feature_plotrasterised its points, and it did so unconditionally. Every other scatter drew one vector path per cell, so a PDF or SVG of a 100k-celldim_plotembedded 100k circles.dim_plot,feature_scatter,variable_feature_plot,image_dim_plot,image_feature_plot,spatial_dim_plotandspatial_feature_plotnow takeraster=alongsidefeature_plot, defaulting toNone— which resolves to Seurat's own rule, rasterise above 100,000 points. PNG output is unaffected either way.
Added¶
- A theme layer:
set_theme,theme_context,get_theme,reset_theme. The module named absolute point sizes at 40 call sites (fontsize=8twelve times,fontsize=9ten times, and so on), so changing the house style meant editing the source. Text now scales from one base size through named roles, andset_theme(base_size=13, style="seurat")moves all of it at once.
base_size also writes rcParams["font.size"], because the roles only cover
text this module sizes explicitly — axis and tick labels are matplotlib's, and
without that a larger base grew the titles and left the axis furniture at
10pt.
Two style presets ship: "seurat" (cowplot's look, which is what Seurat
draws) and "minimal". Neither is applied unless asked for; the default
touches no rcParams at all.
The default base_size=10 reproduces the previous absolute sizes exactly —
0.8 x 10 == 8, and so on. Verified by rendering twelve plots on both sides of
the change with the old palette pinned: all twelve came out byte-identical,
which is also what isolates the palette above as the only behavioural change.
hue_pal(n)is public — the ggplot/Seurat discrete colour scale, useful for matching group colours in a figure drawn outside truecell.
Changed¶
- CI installs from
uv.lock, and so should you. The test matrix ranuv pip install -e ".[all]", which ignores the lock and resolves fresh against the>=floors inpyproject.toml— so CI, and every contributor, could be on a different scientific stack from the one the committed tutorial figures were drawn with.
That is not hypothetical. Rebuilding a development environment moved umap-learn, scikit-learn and NumPy forward, and ten CITE-seq figures shifted by up to 11% of their pixels while every number the vignette asserts stayed identical — Pearson 0.9847, 99.29% label concordance, 16 and 21 clusters, the whole per-cell-type weight table. Clustering runs off the SNN graph; the UMAP embedding is only ever displayed. So the figures drift and no anchor reports it.
uv.lock was itself gitignored, which is the same bug one level down: a
lock only its author has is not a lock. It is committed now. The usual advice
to leave a library's lock out of the repository is about the published
package and still holds — what a user installs is set by the >= floors in
pyproject.toml, which this does not touch. It constrains the development and
CI environment, and that environment has an output: the committed figures.
uv sync --all-extras --locked now installs both matrix legs, and --locked
fails rather than re-resolving if uv.lock has fallen behind
pyproject.toml. docs/installation.md and the truecell-dev skill point at
the same command, so figures are regenerated from the versions CI tested.
- A weekly job resolves dependencies fresh, and fails when they break us.
Pinning CI to the lock gives up what the unpinned install did for free:
noticing that a new numpy or pandas has broken the package a user installing
today would get.
freshdepsis that check, moved rather than dropped — it ignores the lock, runs the suite, and fails loudly instead of reporting advisorily, because a|| truehere would be a green tick on a check that had stopped checking. It runs on a schedule and on demand, not per pull request, so upstream breaking is never mistaken for the pull request breaking.
It resolves under both 3.12 and 3.13, matching the test matrix — an upstream
release can break one interpreter and not the other, and a canary that only
resolved under 3.12 would stay green through it. The legs do not fail fast,
because which legs went red is the diagnosis: one is an interpreter problem,
both is the package.
The distribution job deliberately still installs the wheel unlocked: it
exists to prove a plain pip install truecell works for a real user, and
pinning it would defeat the point.
1.0.0 - 2026-07-29¶
The rename release. shanuz became truecell, and the version moves to 1.0.0
rather than continuing the 0.x line: shanuz 0.9.0 is the last release under
the old name and truecell 0.9.0 was published from the same code before the
rename settled, so neither number was available to carry this. It is the same
codebase, renamed and re-verified, not a maturity claim -- the API is the one
0.9.0 shipped.
Changed¶
- The package is renamed from
shanuztotruecell. This is a breaking change and there is no compatibility shim:import shanuzwill not work, and the top-level class isTruecell, notShanuz. Every import, every skill directory, the documentation site and the logo assets move with it.
shanuz remains on PyPI at 0.9.0 and is not being withdrawn, but it will not
receive further releases; new versions are published as truecell. The
GitHub repository was renamed in place rather than forked, so stars, forks,
issues and pull requests carry over and the old URL redirects — including for
git clone and pip install git+…. Clones with an existing origin keep
working through that redirect, though git remote set-url is worth running.
The logo changed with the name. The mark was a lowercase s; it is now a
lowercase c, and the wordmark was reset in the new name — which meant
drawing five letterforms (t, r, e, c, l) that the six letters of
shanuz had never needed. The two-lobe colour split moved to the c, the
only letter in truecell that is a single unbroken arc and so the only one
whose midpoint is a non-arbitrary place for the colour to change.
Fixed¶
- The sdist shipped the tutorial data, and shipped a different amount of it
every time.
docs/tutorialsis a symlink totutorials/, hatchling follows it, and the tutorials write their intermediates into the directory they live in — the BPCells stores, the.lazymatrices, the R handoff CSVs..gitignorecovers all of those, but an sdist is not built from git, so they were being packaged. The publishedshanuz0.9.0 sdist is 23.5 MB against a 0.24 MB wheel for that reason, and a build on a machine that had just run the tutorials produced 63 MB: the same version number, a different tarball, depending on the disk it was built from.
[tool.hatch.build.targets.sdist] now lists its contents explicitly — the
package, the tests, and the top-level metadata. The sdist is 532 KB. The
wheel is unaffected; it was always scoped by packages = ["truecell"].
tests/test_packaging.py builds an sdist and looks inside it rather than
asserting on the configuration, which would pass just as happily against a
broken artifact.
Added¶
-
A logo (
docs/assets/logo/), generated bytools/make_logo.pyrather than drawn — the mark is a point cloud whose density traces a lowercasec, forcell. Building a picture out of dots is the nod to Georges Seurat, the pointillist the R package is named for. Thecis two arcs meeting at the waist in two colours: the two implementations, one shape. It is also the letter the wordmark's owncdraws, so the mark is a letter of the name enlarged rather than an ornament beside it. Ships as a mark, a horizontal lockup, a wordmark, a hex sticker, a simplified single-stroke glyph for sizes below about 48px where the dots silt up, and favicons; each in a variant for a light ground and one for a dark. Colours are the documentation site's, unchanged, so the two are one system. The site now carries it as its header logo and favicon, and the README as a<picture>that follows GitHub's light and dark modes. -
Agent skills for LLM-assisted work with the package (
skills/). Ten skills in the Claude Agent Skills format — plain Markdown with YAML frontmatter, so they are equally usable as context for any other model. A router (truecell) carrying the API contracts that break code silently, bundled with a full signature-and-Seurat-equivalent API map and an object-model reference; then one skill per area:workflow,differential-expression,integration,multimodal,spatial,at-scale,plotting,from-seurat, anddevfor working on the package itself.
The content is aimed at the mistakes a model actually makes against this API
rather than at re-describing the docs: that analysis functions mutate in place
and return None (so obj = normalize_data(obj) leaves you holding None),
that dims is 0-based, that matrices are features × cells, that the generics
are not top-level, and — in each domain skill — which differences from Seurat
are deliberate and must not be "fixed". Every signature, default and measured
number in them was derived from the installed package or from a recorded R
comparison; a validation pass resolves each truecell.* and generics.* name
in all thirteen files against the live package.
.claude/skills is a symlink to skills/, so Claude Code discovers them when
run in this repo — the same idiom docs/tutorials already uses, and for the
same reason: one copy, not a second one that drifts.
Fixed¶
-
The API reference claimed the whole API is exported from the top level.
docs/api/index.mdopened with "Everything on these pages is exported from the top level", which is true for eleven of the thirteen pages and false for two. 66 of the 73 public generics live only ontruecell.generics, sotruecell.features(obj)— the call the sentence invites — raisesAttributeError; the loaders on the Loading data page are likewisetruecell.io.read_10x,truecell.datasets.pbmc3k,truecell.compat.anndata.as_anndata. Rewritten to say most, name both exceptions, and list the seven generics that genuinely are re-exported.tests/test_docs.pynow pins the counts, since prose is the one thingmkdocs build --strictcannot check. -
tutorials/README.mddocumented only 10 of the 18 tutorials. The overview table at the top listed all 18, but the detailed walkthrough sections stopped after Tutorial 10 and jumped straight to the API quick reference — so the eight tutorials added in Waves 2 and 3 (the object model, dimensional-reduction extras, sketching, the spatial container and Moran's I, the DE test suite, out-of-coreLazyMatrix, Visium, and anchor internals) had no written section, on GitHub or on the docs site. Added all eight, in the same format as the rest. -
Most figures on the docs site 404'd. MkDocs re-anchors relative paths written in Markdown syntax onto the built page, which under
use_directory_urlssits a directory deeper than its source — but it passes raw HTML through untouched, and the vignettes write most of their figures as<img>inside HTML tables so the R and the Python plot sit side by side. Those resolved one directory too deep: 110 of the site's 133 figures were broken, and the ten vignettes built entirely out of those tables showed no images at all. Added a build hook (tools/mkdocs_html_relpaths.py) that applies the same rewrite to raw<img src>, so the one path in the source stays correct both on the site and on GitHub, where the vignettes are also read. A reference with no file behind it is now a warning, whichmkdocs build --strictturns into a failed CI build.
The existing figure-existence test only understood Markdown image syntax, so it was blind to 126 of the 149 references it was meant to be guarding; it now reads both syntaxes.
0.9.0 - 2026-07-27¶
Work from six milestones — reference mapping, extra reductions, pseudobulk DE,
spatial, scale, and the specialized assays — plus one breaking fix, plus the
tutorial fidelity infrastructure that followed (measured bands, staleness
guards on the R-comparison reports). All of it was on main; as of this
release, all of it is on PyPI.
Added¶
- A documentation site. MkDocs + Material + mkdocstrings, built from
mainand published to GitHub Pages, at genomicai.github.io/truecell. The API reference is generated from the docstrings — which is the point: eighteen tutorials' worth of hard-won fidelity notes lived in them and rendered nowhere. Alongside it, four hand-written pages (overview, installation, a quickstart whose every line was executed to produce the output it shows, and Fidelity, which collects how the port is checked against R, what the checking has caught, and the differences that are real), plus all eighteen vignettes and their figures.docs/tutorialsis a symlink totutorials/, so there is one copy of each vignette rather than a docs copy that drifts.
The blocker recorded for this item was not real. ROADMAP.md said
mkdocstrings resolves annotations and that typing.get_type_hints() raising
NameError on the plotting and graph/neighbor signatures would stop the
site on day one. It still raises, on 19 callables — but mkdocstrings reads
annotations statically through griffe, without importing the module, so those
signatures render and cross-link because the if TYPE_CHECKING: imports are
there. Measured rather than assumed, and written up on the Fidelity page.
Building it surfaced defects the site was the first consumer to notice:
- Three
Returnssections were being parsed as parameters.sctransform,add_module_scoreandcomposition_testeach ended their parameter list with a bareReturns ...sentence at the same indentation, so a NumPy parser read it as another parameter and the functions documented no return value at all.composition_testalso had its eleven output column names parsed as eleven parameters. - Every one of the 370 documented parameters put its description in the
type slot. The convention across the package is
name : what it does— prose, never a type, because the types are in the signatures. NumPy format reads that slot as the type, so the site printed "min features a cell must have to be kept" asmin_features's type and left its description empty. Fixed intools/griffe_sphinx_roles.py, a build-time translation, rather than by rewriting 370 lines of source that are not wrong. - 129 Sphinx cross-reference roles rendered as literal
:func:text. The same extension turns them into real links, but only when the target is one the site actually publishes; anything else degrades to a code span, because--stricttreats an unresolvable link as a failure. - Fifteen
Slotsblocks collapsed into one run-on line. Markdown joins consecutive lines, so an aligned column block became a paragraph. Rewritten as bullet lists in the source, which reads better underhelp()too. - Two heading anchors in
xenium_spatial_tutorial.mdwere dead under python-markdown's slugger while being correct on GitHub. Fixed by adopting GitHub's slugger site-wide, so one set of links works in both places.
tests/test_docs.py asserts the parts that rot silently: every public export
appears on an API page, every ::: directive names something that still
exists, no symbol is rendered twice, every nav entry and every vignette figure
resolves, and the whole site builds under --strict. All nine mutations of
those guards were killed.
- The two R-comparison numbers that were allowed to move are now asserted
bands, and the reports refuse a stale R reference.
deseq2's overlap with Seurat's top 50 and JackStraw's PC cutoff both differ from R for understood reasons — pseudobulk against per-cell in one case, a seeded permutation against R's loop-indexed one in the other — and both were recorded as prose. Prose does not fail, so a genuine regression landing inside the expected spread read as ordinary variation.tutorials/bands.pygives each number aBandwith a stated reason;--reportprints the verdicts and exits non-zero outside them. The bands are measured, not guessed: a 60-seed sweep ofjack_straw(keeps of 12/13/14/15 for 2/28/11/19 seeds against R's deterministic 13) and 20 resampled pseudo-replicate splits fordeseq2(20–26, median 22). The seven cell-level DE tests get exact bands, so one dropped gene out of the top 50 is now a failure.
Three defects surfaced while wiring it up, all the same shape — an R reference silently older than the handoff it answers:
pbmc3k_de_tutorial.py --reportcompared across runs without noticing. On the working copy the Python tables were from 25 July and the R tables from 19 July, taken on a different cluster assignment, and the report printed a full parity table withwilcoxat 48/50 and a p-value Spearman of 0.907 — indistinguishable from a regression in the port. Re-run against a matching reference,wilcoxis 50/50 at Spearman 1.000000.pct.1/pct.2are counts over the shared cells with no statistics in the way, so they now gate the comparison: they had differed for 12,491 of 13,712 genes.pbmc3k_dimreduc_tutorial.pyNaN-filled instead of failing._readreindexes R's embedding onto the Python cell order, which fills an absent barcode withNaNrather than raising; every correlation downstream would have printed asnan. The feature check is now set equality rather than one-sided containment — an R run holding extra features passed before.- Running
pytestcorrupted the R handoff.prepwrotefigures_dimreduc/hvg_features.txtandcells.txtunconditionally, so the test suite's synthetic fixtures replaced the 2,000 real HVGs with 100 genes namedGENE171and the 2,700 barcodes withCELL0..CELL119— whichpbmc3k_dimreduc_verify.Rreads.prepnow takesout_dir.
Both vignettes are refreshed against re-run R references. The dim-reduction
basis check improves from median |r| 0.9759, matched through PC 15 to
1.0000, matched through all 20: the old figure was not PCA disagreement at
all, but 6 of the 2,000 HVGs having drifted across the selection boundary
since the reference was built. All eight mutation trials against the new
guards were killed.
- The PBMC 3k and PBMC 8k tutorials now have numeric handoffs against R
Seurat, closing the last two compared entirely by eye. Every R panel in the
PBMC 3k walkthrough links a canonical satijalab.org image, so nothing failed
if the numbers behind those pictures drifted.
pbmc3k_verify.Rnow writes per-cell QC and PCA keyed by barcode, per-gene VST statistics, the marker table and a set of scalar anchors;pbmc8k_subclustering_verify.Rwrites the global cell table and the T/NK compartment's membership. Both tutorials gained a--reportthat compares them. Neither side is pinned to the other — both run their own pipeline from the same 10x bytes, which is what makes the comparison mean anything.
PBMC 3k against Seurat 5.5.1: the same 2,638 barcodes survive QC (nCount and nFeature exact, percent.mt to 5.3e-15), the VST means agree to 4.8e-14, 1,998 of 2,000 variable features are shared, PCA over the 10 dims the clustering uses matches at |r| 0.9988 with no reordering, the kNN graph is 52,760 edges on both sides, and the clusters agree at ARI 0.938 (8 vs 9 — the one Seurat has and truecell does not is a 32-cell dendritic-cell population whose cells land, all 32, in truecell's CD14+ Mono cluster).
PBMC 8k: the same 7,475 barcodes, global clusters at ARI 0.977, and the T/NK compartment handed to the subclustering stage matches at Jaccard 0.9991 — 4,631 of 4,635 cells are the same barcodes. Subclusters then agree at ARI 0.916 and the subset labels at 98.2%. The compartment is compared by barcode on purpose: everything in stage two is conditioned on which cells stage one selected, so a compartment of the right size drawn from the wrong clusters would pass a count check and make every later number incomparable.
Two stale claims fell out. tutorials/README.md recorded "Clusters at
resolution 0.5: 9 ✅" and "All 6 canonical cell types recovered … DC ✅" for
PBMC 3k; truecell finds 8 and does not separate DC on that dataset. Both, and
the vignette's "9 clusters in both R and Python", are now the measured
numbers with the provenance traced from the LOESS fit down to the 286 SNN
edges that move the boundary. PBMC 8k runs the opposite way — there truecell
splits Seurat's merged Platelet/DC cluster in two — so neither run is
uniformly finer, which the docs now say.
- The CITE-seq tutorial now compares the CLR transform and the WNN weights,
per protein and per cell.
cbmc_citeseq_verify.Rdumps the per-protein CLR summary and the per-cell modality weights keyed by barcode;--reportcompares them. Keying by barcode is the point: a mean-per-cell-type table asks two different cluster partitions the same question and cannot separate "the weights differ" from "the labels differ".
Measured against Seurat 5.5.1: ADT CLR agrees to 4.2e-15 on every statistic, per-cell ADT weight correlates at Pearson 0.9847 (Spearman 0.9816, median abs diff 0.0152) over 8,617 shared barcodes, cell-type labels are 99.29% concordant, and cells, genes, proteins, RNA clusters and WNN clusters all match exactly.
This settles the progenitor question the vignette had left open. It
recorded progenitor 0.06 away from Seurat where every other cell type agreed
to 0.02, and said explicitly that the small-population-noise explanation was
"a reading, not something this tutorial establishes". Re-grouping truecell's own
weights by R's labels puts progenitor at 0.283 against R's 0.285 — the
weights agree; 61 of 8,617 cells sit on the progenitor/erythroid boundary and
land differently. Not a WNN difference.
- The SCTransform tutorial now compares the fitted model, not just the
figures. pbmc3k_sctransform_verify.R dumps Seurat's own
SCTModel.list[[1]]@feature.attributes plus the ranked variable features and
the clips; the Python side writes the same table and
--report puts them side by side, per gene. This was the highest-risk gap in
the suite: tutorials 1-4 were compared by eye, and SCTransform is where a
four-part defect once hid behind a perfectly plausible UMAP.
- sctransform now stores the whole fitted model on the SCT assay's
meta_data, under Seurat's column names: residual_mean, detection_rate,
(Intercept) and log_umi join the existing residual_variance, theta and
gmean. The regularized intercept and slope — the model itself — previously
could not be inspected at all.
Measured over all 12,572 modelled genes against Seurat 5.5.1: detection_rate
and gmean agree to 5.6e-16 and 1.2e-12, (Intercept) and theta both rank
identically (Spearman 1.0000), the 3,848 genes v2 declares non-overdispersed
are exactly the same set (Jaccard 1.0000), and residual variance ranks at
0.9986. residual_mean is the one column that does not track by rank
(Spearman 0.71, Pearson 0.99) and the one nothing downstream reads.
Changed¶
- BREAKING:
find_variable_featuresnow writes the column namesHVFInfo()returns. On an Assay5 the per-feature statistics land inassay.meta_data, which — unlike SeuratObject, where they sit behind avf_vst_counts_prefix thatHVFInfo()strips — is the user-facing table. It spelled themmeans/variances/variances.standardizedagainst Seurat'smean/variance/variance.standardized, so the same quantity read differently in the two languages and the PBMC 3k handoff had to rename all three on the way out for the join to work. Now:
| method | columns |
|---|---|
"vst" |
mean, variance, variance.expected, variance.standardized |
"mvp" / "dispersion" / "mean.var.plot" |
mvp.mean, mvp.dispersion, mvp.dispersion.scaled |
Code reading meta_data["means"] and friends must be updated; the old names
are gone rather than aliased, since keeping both is what let the divergence
live in the object unnoticed. highly_variable is unchanged — it is truecell's
own flag, and HVFInfo(status = TRUE)'s variable has no other counterpart.
Two things fall out of the rename. variance.expected is new: the vst
LOESS fit was computed, used to standardize, and then discarded, so nothing
downstream could tell a genuinely variable gene from one sitting where the fit
was poor. On PBMC 3k it agrees with Seurat to 2.50e-2 relative against
variance.standardized's 2.56e-2 — the two carry the same disagreement,
as they must, since the standardization is proportional to its reciprocal.
That is the whole of the VST gap, now attributable: the mean and the observed
variance match to 1.5e-14 and 5.1e-14, so what the two tools disagree about is
the LOESS fit and nothing else. And the mvp path no
longer writes vst's names: it had been storing scaled dispersions in a
column called variances.standardized and the raw variance in one called
variances, neither of which is a quantity HVFInfo(method = "mvp") reports.
Those values differ from Seurat's in definition as well as in name — truecell
computes them on log-normalized data where Seurat round-trips through
expm1 — so this fixes the labels, not the numbers.
variable_feature_plot reads the stored statistics and silently falls back to
recomputing a raw dispersion when it cannot find them, so a consumer left on
the old spelling would have kept drawing a plot — a different one, on a
different axis, with no error. tests/test_hvf_column_names.py pins the
column names against a recorded Seurat 5.5.1 probe, pins variance.expected's
contents through the identity variance.standardized = variance /
variance.expected (exact wherever the clip does not bite), and asserts the
plot takes the stored-statistics branch. The PBMC 3k handoff now carries
var.expected on both sides, which is what separates "the standardization
drifted" from "the LOESS fit did".
- The SCTransform vignette's ±1 cluster gap is closed and its docstring
corrected. The vignette described truecell resolving 13 clusters against
Seurat's 12, blamed on the RNG and the differing clustering libraries. Both
arms now agree exactly — 12 under SCTransform, 11 under LogNormalize — which
PR #55's graph work closed rather than anything in SCTransform. Separately,
truecell/sctransform.py's module docstring claimed 99.7% variable-feature
agreement and theta at 0.96; the reproducible comparison measures 97.1% and
Spearman 1.0000. The docstring now carries the numbers the tutorial prints.
- The object-model tutorial's R reference now uses exact neighbours.
pbmc3k_objects_verify.R called FindNeighbors with Seurat's default
nn.method = "annoy", which is approximate, while truecell's neighbour search
is exact — so the graph anchors compared two different neighbour tables and
reported a difference belonging to annoy rather than to either object model.
It cost 182 SNN edges (199,434 against 199,616). Pinning nn.method = "rann"
makes both sides exact, and the tutorial now matches 91 of 91 anchors,
up from 90. Confirmed two ways: Seurat under rann returns 199,616 directly,
and feeding Seurat's own rann indices into _build_snn reproduces both the
count and the weight sum.
- objects_vignette.md, tutorials/README.md, ROADMAP.md and the tutorial's
own docstring described the symmetrised kNN graph and the dropped SNN
self-edge as open. Both were fixed below; the text and the kNN-degree figure
now record them as closed.
Fixed¶
- One test had been shadowed by a same-named copy and never ran.
test_anchors_seurat_parity.pydefinedtest_pca_loadings_are_exact_not_randomizedtwice; Python keeps the second, so the earlier one — which additionally proves sklearn's randomized solver disagrees on the trailing PCs, i.e. that the exact SVD matters at all — was collected by nothing. Renamed and now runs (and passes). sctransform(vars_to_regress=...)was tested against no baseline. The guard asserted only that the residuals come back uncorrelated with the covariate, which is equally true when there was never any covariate signal to remove; it computed abeforefrom a different object built from un-injected counts and then never asserted on it. It now runs SCTransform twice over the same injected counts and requires the unregressed residuals to carry the signal (0.25) before requiring the regressed ones not to (0.00).find_markersnamed the missing ident in one error and not the other. An emptyident_2raised a constant"No cells found for comparison group."while theident_1branch named what it looked for. Both now name it, and theident_2=Nonecase says there is nothing left outsideident_1rather than reporting a parameter the caller never passed.-
ruff checkis clean, at both scopes — 51 → 0 intruecell, 201 → 0 for the repo. Mostly unused imports left behind by the Assay5 refactor, plus dead locals and semicolon-joined statements. Two of the dead locals were computed colour maps invln_plotanddim_plotthat nothing read, and one was av3layout flag inread_10xthat no branch consumed. The 121E402s intests/andtutorials/are the deliberatesys.pathbootstrap those files need to run from a clone, and are now ignored by scope inpyproject.tomlrather than by 121 scattered# noqa. Note CI resolves ruff unpinned, which currently means 0.16.0 and a much larger default rule set (PEP 604 annotations, import sorting); this entry is measured against 0.15.20. -
Any plot with more than 36 groups raised
AttributeErroron matplotlib 3.9 or newer._palettefills the first 36 colours from a fixed list and falls back to a colormap past that, and the fallback calledplt.cm.get_cmap(name, lut)— deprecated in 3.7 and removed in 3.9. The project has supportedmatplotlib>=3.7throughout, so on any current install the branch was dead code that crashed the moment it was reached. Nine plotting functions route through it; no test had ever asked for that many groups. It now uses.resampled, the documented replacement, which reaches back to 3.6 and so works across the whole supported range. - A layerless v5 assay came back as
KeyError: None.Assay5.default_layerisNonewhen the assay holds no layers, and the two layer getters — inpreprocessingand inplotting— indexed the layer dict with it directly. Both now raiseValueError("No layers available."), matching whatAssay5.layer_datahas always said for the same condition. -
multiseq_demuxand_integrate_anchor_reductionrebound their own parameters, the same idiom as the dataset loaders below; both use a local now. No behaviour change, but neithermultiseq_demux'sqrangenorintegrate_layers' single-elementgroup_bylist had any test coverage, so both are now pinned. This clears the last of the package's type-checker errors:mypy truecellreports none, down from 44. -
cbmc_citeseqreported an unrelated error when the species filter matched nothing. Aspecies_prefixthat no gene starts with — the wrong one, or a file whose row labels are not prefixed — left every chunk empty and surfaced as scipy'sblocks must be 2-D, and some must be sparsefromsp.vstack, which names neither the file nor the prefix that caused it. It now raises aValueErrornaming both. -
The four oldest dataset loaders rebound their
data_dirparameter to aPath.pbmc3k,pbmc8k,cbmc_citeseqandxenium_mouse_brainassigned aPathback over a parameter declaredOptional[str], which was most of the package's remaining type-checker noise (33 of 44 errors, now 11). They use a localPathlike the newer loaders alongside them. No behaviour change — but nothing had ever checked that an explicitdata_dirwas honoured at all, so that is now covered, along with the RNA chunk stitching every realcbmc_citeseqload depends on and none of the tests reached. -
The
mvpstatistics were three different quantities under Seurat's names. PR #64 renamed the dispersion path's columns tomvp.mean,mvp.dispersionandmvp.dispersion.scaled; the numbers underneath were not whatHVFInfo(method = "mvp")returns.CalcDispersioncallsFastExpMeanandFastLogVMR, and both undo the log first:mvp.mean = log1p(mean(expm1(x))) mvp.dispersion = log(var(expm1(x)) / mean(expm1(x))) # sample variance
truecell took the mean and variance of the log-normalized values directly, with
an epsilon inside each logarithm. On PBMC 3k its mvp.mean column ran 0–2
where Seurat's runs 1–7. Three further divergences fell out of the same
function: the bins were equal-frequency percentiles of log(mean) rather
than R's 20 equal-width bins across the mean's range, which changes every
z-score because the scaling is per bin; a bin holding a single gene was
scored 0 where R's sd of one value is NA; and the within-bin standard
deviation used ddof=0 against R's sd. Now verified against Seurat 5.5.1
on all 13,714 PBMC 3k genes — mvp.mean 8.9e-16, mvp.dispersion 2.2e-15,
mvp.dispersion.scaled 5.3e-15, and the one NaN lands on the same gene.
- mean.cutoff and dispersion.cutoff were accepted and discarded. Seurat
has two selectors here, not one: MVP ("mvp" / "mean.var.plot") keeps
every gene inside both cutoffs and ignores nfeatures, while DISP
("dispersion" / "disp") takes the top nfeatures and ignores the cutoffs.
truecell ran the second for every spelling, so mean.var.plot returned exactly
nfeatures genes under a name that promises a cutoff — 2,000 on PBMC 3k
against Seurat's 1,006. Both selectors also rank by the raw
dispersion; truecell ranked by the scaled one, which reorders the list. All
four spellings now return Seurat's features in Seurat's order. "disp" is
newly accepted, and num_bin / binning_method are exposed.
- variable_feature_plot ignored the mvp columns. It looked for the vst
statistics and, not finding them, fell through to recomputing
E[x²] − E[x]² off the data matrix — a third quantity, on a third scale,
drawn without complaint under a y axis reading "Dispersion". It now plots
mvp.mean against mvp.dispersion as VariableFeaturePlot does.
- Re-running find_variable_features with another method left the previous
method's columns behind. SeuratObject can hold both because it namespaces
them by method and layer (vf_vst_counts_mean) and makes you name one to read
them back; truecell's meta_data has a single flat name per statistic. A vst
run followed by an mvp run left variance.standardized sitting beside an mvp
variable_features list it did not describe — and that is what made
variable_feature_plot draw the vst figure over the mvp genes. Whichever
method runs now owns the table.
- add_module_score read the expression matrix once per gene, and was 100×
slower than it needed to be. Each gene's row was pulled out on its own
(mat[i, :] inside a list comprehension) and the stack handed to np.mean.
Every assay layer here is CSC, so slicing a single row walks the whole
column-major matrix: on the THP-1 ECCITE data (18,381 × 20,729, 69.5M
nonzeros) that is ~22 ms per gene, and the default ctrl=100 draws a couple
of thousand control genes. 50 of 51 profiled seconds were inside one scipy
call, get_csr_submatrix, invoked once per gene. Now one row selection per
gene set, transposed to CSR so the rows are summed in the order they were
asked for:
| before | after | |
|---|---|---|
cell_cycle_scoring (THP-1, 2 programs) |
168.4 s | 0.90 s |
add_module_score (30-gene program) |
51.3 s | 0.51 s |
R's CellCycleScoring takes ~12 s on the same data, so truecell now runs it an
order of magnitude faster rather than 14× slower. The spelling is load-bearing
and bit-identical to the old arithmetic: an indicator-vector matvec
(ind @ mat) is faster still but lands 7.5e-16 away, and summing the CSC
selection directly 2.5e-15 away, because each accumulates the columns in a
different order.
- The same seed gave a different module score in a different process. The
control genes were collected in a set, and a mean depends on the order its
terms are added. Python randomises str hashing per process, so iterating
that set summed the control expression in a different order every run — the
same object at seed=1 scored 9.7e-16 apart in two processes. A dict now,
which is also R's semantics (AddModuleScore applies unique() to the
sampled names and indexes the matrix with the result). THP-1 S.Score,
G2M.Score and the interferon program move by at most 2.1e-15 against the old
code — which had no fixed value to move from — and all 20,729 Phase calls
are unchanged.
- A reduction that could not use every feature you asked for labelled its
loadings with the features you asked for anyway. _get_scaled_data filtered
the request down to what the layer carried and returned only the matrix, so
run_pca stored n names against fewer than n rows and every row below the
first dropped feature answered to the wrong gene. viz_dim_loadings and
dim_heatmap read those names straight onto the axis, and jack_straw handed
each per-feature p-value to its neighbour. Nothing raised; every number was
individually correct. Seurat's PrepDR5 drops the same features but warns
naming them, and takes its rownames from the subset matrix so the labels
cannot drift from the rows. Both halves are ported: the drop now warns with the
gene names (RuntimeWarning, first 20 named then a count), requesting nothing
the layer holds is an error rather than an empty matrix, and run_pca,
run_spca, run_ica and jack_straw all take back the list that labels the
rows they got. On the ordinary path — PBMC 3k, 2,000 variable features, nothing
dropped — embeddings and loadings are bit-identical to before.
- run_pca on a v3 Assay that had been through scale_data() read the
wrong genes, or crashed. Assay.scale_data is a bare ndarray holding only
the scaled subset — the variable features, by default — with no record of
which subset; R's slot is a matrix and carries its own rownames. Four places
invented the missing labels, differently: _get_scaled_data indexed the layer
by each gene's position in the assay, features("scale_data") returned the
assay's leading n features, subset() discarded the layer whenever it was
not full height (i.e. always), and mapping._scaled_feature_names returned the
full feature list. The first either raised IndexError or returned another
gene's row, deterministically, on the standard workflow. Assay now carries
_scaled_features alongside scale_data, mirroring Assay5; the constructor
refuses to guess labels for a subset rather than inventing them, subset()
subsets the layer by name, and the v3 and v5 paths now return identical
scaled matrices.
- Integration anchors intersected the feature set against the wrong list.
_integration_features filtered against the assay's full feature list — while
its own comment claimed it checked "scale/data" — and _anchor_feature_matrix
filtered against the scaled layer, so a feature one object had never scaled
survived the first filter and was dropped by the second, in that object
alone. The reference and query matrices are multiplied row-for-row, so that
is either a shape crash or two matrices describing different genes. The
intersection is now taken against the layer the anchors are built from, and
_anchor_feature_matrix raises rather than warns if anything is still missing.
- run_pca on an Assay5 that had not been scaled raised "the truth value of
an array … is ambiguous". _get_scaled_data chose its fallback layer with
layers.get("data") or layers.get("counts"). or evaluates bool() on its
left operand, and scipy refuses to answer for any matrix with more than one
entry — so the fallback raised on every call that reached it, which is
every run_pca or run_ica on a v5 object that had not been through
scale_data(). The v3 path never had it: it reads assay_obj.data
directly, so the two architectures silently disagreed about whether an
unscaled object could be reduced at all. Selected with an explicit is None
now, and if neither layer exists the error says which ones it looked for.
The v5 fallback returns bit-identical embeddings to the v3 one it mirrors.
- Merging a v3-backed and a v5-backed object died with an
AttributeError about a private slot. Assay keeps its cells in
_cell_names and Assay5 in _all_cell_names, so Truecell.merge reached
for whichever the other did not have and failed partway through — after the
cell names and metadata had already been concatenated, from inside a method
the caller never named. It now raises a TypeError naming the assay and both
classes, and pointing at create_truecell_object(..., use_v5=) as the way to
make the two sides match. Merging assays of the same class is unaffected.
- StdAssay's methods returned the abstract base rather than the caller's
own class. join_layers, split_layers, cast_assay, subset,
rename_cells, merge and _copy were annotated -> "StdAssay" while every
one of them builds its result from self.__class__, so an Assay5 went in
and something typed as an ABC came out. Every caller downstream then had to
widen, which is what put dict[str, Assay | StdAssay] where Truecell wanted
dict[str, Assay | Assay5]. Annotated Self, which is both accurate and what
the code already did. tests/test_object_model_typing.py pins the runtime
property that makes it true — a subclass gets its own class back from all
seven — since hardcoding Assay5(...) in any of them would leave the
annotation a promise the code does not keep and no other test would notice.
- An unreachable second implementation of the object's count columns is
gone. create_truecell_object dispatched on hasattr(assay_obj, "calc_n"),
which both assay classes satisfy, so the _calc_n_for_assay5 fallback could
never run. It derived the nCount_<ASSAY> suffix from the assay's key
rather than from the assay argument and hardcoded nCount_RNA for an assay
with no default layer — agreeing with the live path for an ordinary RNA
object, which is why nothing had reason to look at it.
Together these clear all 14 mypy errors in the object-model modules
(truecell.py, assay5.py, reduction.py, spatial/fov.py), taking the
advisory total from 60 to 46. The remaining 46 are elsewhere: 40 in
datasets.py (one repeated idiom — a cache_dir: Optional[str] parameter
reassigned to a Path), and six spread over plotting, mixscape, multiseq,
sctransform, preprocessing, module_score and integration.
- truecell/command.py began with a UTF-8 BOM. CPython decodes source as
utf-8-sig, so the file imported, introspected, linted and type-checked
without complaint — which is how three bytes survived unnoticed. What they
broke is reading the source back as text: Path.read_text() defaults to plain
utf-8, keeps the U+FEFF, and ast.parse then rejects the file outright with
invalid non-printable character. The AST walk added alongside this had to
decode around it, and any codemod, doc generator or source-level lint would
hit the same wall on a file that looks fine. The BOM is gone, and
test_no_source_file_starts_with_a_byte_order_mark scans the whole repo — not
just the package — so another cannot arrive quietly.
- Twenty-one annotations named symbols that existed nowhere at module scope.
Seventeen plotting functions were declared
-> "plt.Figure"whilepltwas only ever a local inside each body (plt = _mpl());Graph.as_neighbor,Neighbor.as_graphandfrom_anndatanamed classes imported at call time to dodge a circular import.from __future__ import annotationsmakes every signature an unevaluated string, so this cost nothing at runtime and no test noticed — but it is exactly what a type checker, an IDE, or a documentation generator reads. Both mypy (name-defined) and ruff (F821) had been reporting all 21 for as long as the annotations existed.
Each is now a if TYPE_CHECKING: import, which those tools read and the
interpreter never runs. The plotting signatures say -> "Figure" from
matplotlib.figure, which is the class the functions actually return.
matplotlib stays an optional dependency: import truecell eagerly imports
truecell.plotting and still does not pull it in.
mypy drops 81 → 60 errors and ruff 72 → 51 on truecell (222 → 201
repo-wide) — the same 21 in both counts, so any baseline recorded before this
is 21 high on both tools. tests/test_annotations_resolve.py walks the AST of
every module in the package and fails on any annotation whose root name is not
bound at module scope, and separately asserts each deferred import stays
inside its TYPE_CHECKING block — hoisting it would satisfy both checkers
while making matplotlib mandatory.
find_all_markerswas missing Seurat'sreturn.thresh, and did not break p-value ties.FindAllMarkersdefaults toreturn.thresh = 1e-2and returns only genes below it; truecell returned everything that survived themin_pctandlogfc_thresholdpre-filters, including plainly non-significant rows. The PBMC 3k handoff found it the decisive way: two clusters came out with identical cell membership on both sides, and on those two truecell returned 190 and 383 genes against Seurat's 151 and 242 — every extra row a gene the two agreed about numerically (maxavg_log2FCdifference 4.9e-15) and that Seurat simply does not return. Applying the same filter reproduces Seurat's gene sets exactly, 151/151 and 242/242.
Rows are now also ordered by p_val ascending then avg_log2FC descending
within each cluster, matching order(gde$p_val, -gde[, 2]). This is not
cosmetic: Wilcoxon p-values tie at exactly 0 for the strongest markers — 40
to 302 genes per cluster on PBMC 3k — so without the tie-break "the top N
markers", which is what every tutorial prints, was decided by incoming row
order. Pass return_thresh=None for the old unfiltered table.
Cluster labels are also now iterated numerically rather than lexicographically, so a dataset with eleven or more clusters comes back in Seurat's factor order instead of 0, 1, 10, 11, 2, …
The neighbour graphs, against FindNeighbors / FindClusters (Seurat 5.5.1).
Four defects, found while establishing that the clustering divergence left open
above is not a defect — given the same embedding the two tools' KNN indices
are identical and their SNN graphs agree to 2.8e-08, and the partitions differ
only because Seurat's n.start = 10 restarts find 0.17 % more modularity by
splitting CD14 Mono along the batch axis (73.8 % CTRL / 83.3 % STIM). truecell
scores ARI 0.9195 to seurat_annotations against Seurat's 0.7368, so the
Louvain search was deliberately left alone.
find_neighborssymmetrised the KNN graph. Seurat'snnis the raw ranked neighbour table —sparseMatrix(i, j, x = 1), no symmetrisation — sonnzis exactlyn * k, every row sums tok, and the column sums vary with in-degree (21 to 68 on ifnb).mat + mat.Tinflatednnzand flattened that spread, discarding which cells are hubs. Both in-tree consumers (run_umap, the graph branch ofrun_spca) already symmetrise at the point of use, as Seurat's own do.find_neighborsdeleted the SNN diagonal.ComputeSNNstoresSNN[i,i] = k / (2k − k) = 1for every cell; all 13,999 were present in Seurat's graph and none in truecell's. It was invisible tofind_clusters, whose igraph conversion takes the strict upper triangle and so discarded precisely the entries that were missing.run_umapdid not strip that diagonal.RunUMAP.Graphopens withdiag(x = object) <- 0. With the diagonal restored above, omitting this would feed the layoutnzero-length self-edges.find_neighborscomputed Jaccard weights in float32. ~3e-08 off Seurat on every weight, now 4.4e-16. This never changed which edges were pruned — a weak Pythonprune_snnis cast down to float32 for the comparison, so both sides round identically — only the stored values.
Added¶
find_clusters(group_singletons=True), porting Seurat'sGroupSingletons. Size-1 clusters are absorbed into whichever non-singleton cluster they are most connected to, scored by mean SNN weight (a sum would hand them to the largest cluster instead), with the candidate list fixed before the loop so one singleton cannot absorb another.Falsepools them into a single"singleton"cluster, again matching Seurat.
Fixed¶
The v4 anchor path, against FindIntegrationAnchors / IntegrateData
(Seurat 5.5.1). Twelve defects; anchor agreement went from 70.0 % to 99.9 %
for CCA and to 100 % for RPCA.
find_integration_anchors(reduction="cca")built the wrong shared space.RunCCAstandardizes each cell (z-score) before the cross-covariance SVD; truecell L2-normalized it. That is a correlation matrix between cells versus a cosine-similarity one — different singular vectors, so every anchor moved.- Anchor features constant in either object are now dropped, as
RunCCA'sCheckFeaturesdoes (83 of 2,000 on ifnb). Standardizing works down each cell, so a constant gene still shifted that cell's mean and SD. - The anchor filter now runs on
TopDimFeaturesof thedatalayer.FilterAnchorsuses at most 200 genes chosen from the CCA loadings and the log-normalized values; truecell used all 2,000 anchor features ofscale.data. - The anchor score now uses four neighbour tables.
ScoreAnchorsgives each memberk.scoreneighbours within its own dataset plusk.scorein the other. A single pooled kNN is nearly all same-batch when a batch effect is present, collapsing every score toward the floor. - Filtering now happens before scoring. The score is rescaled against the 1st/90th percentiles of the set it is handed, so scoring first took those percentiles from anchors that were then discarded — shifting every surviving value (mean 0.5477 vs Seurat's 0.4971, only 10.5 % identical).
k_filternow retains every anchor when either dataset is smaller than it, as Seurat does, instead of clampingkto the query size._pca_loadingsnow uses an exact SVD. sklearn'sPCAswitches to a randomized solver abovemax(shape) > 500, which drifts in the trailing components — only 12–14 of 30 PCs matched irlba above 0.99. Reciprocal PCA standardizes each projected dimension by its own SD, which is not rotation-invariant, so a drifted axis became a different reciprocal space: RPCA anchor recall 44.9 % → 100 %.integrate_datanow weights in a PCA of the merged pair.RunIntegrationre-scales the reference and query together on the anchor features and runs a fresh PCA; truecell reused the CCA embedding, a space built to make the batches overlap.- The weight kernel now matches
FindWeightsC—1 − exp(−d̃ · score / (2/sd)²)overd̃ = 1 − d/dₖ, with the score inside the exponent — instead of a Gaussian in the raw distance times the score.sd_weightnow widens the kernel as it grows, which is the opposite of what the old bandwidth did. k_weightcounts anchors, not anchor cells. The neighbour search runs over the unique query anchor cells and expands each into all of its anchor rows, stopping atk_weightentries.integrate_dataraises when there are fewer distinct query anchor cells thank_weight, asFindWeightsdoes, instead of silently shrinkingkand averaging over far fewer anchors than asked for.integrate_layersnow corrects onto the larger batch.PairwiseIntegrateReferencereverses the merge pair when the second object is bigger; truecell always took the first. Invisible on an even split, and ifnb is CTRL 6,548 vs STIM 7,451.
These also reach find_transfer_anchors / transfer_data, which share the
scoring, filtering and weighting helpers: panc8 label-transfer accuracy went
0.9845 → 0.9862 and per-cell concordance with R 0.9871 → 0.9883.
The v5 IntegrateLayers path, against CCAIntegration / RPCAIntegration
(Seurat 5.5.1). Two more defects, found by treating what had looked like an
"expected implementation gap" on the v4 path (above) as a claim to check.
Embedding agreement on a 2,400-cell probe went from 1/30 PCs above |r| = 0.99
to 30/30 for both reductions; on the full 13,999-cell, unequal-batch ifnb,
RPCA batch mixing rose from 0.867 to 0.991 (Seurat: 0.917).
integrate_layers(method="cca"|"rpca")was running the wrong algorithm. Seurat'sIntegrateLayers(method = CCAIntegration/RPCAIntegration)does not callIntegrateData— it callsIntegrateEmbeddings, which corrects the input PCA embedding directly (transposed into a fake per-dimension assay and pushed through the same anchor-weighting machinery) and returns it in the same basis, loadings included. truecell was running the v4 workflow behind that name — correct expression, re-scale, re-run PCA — landing in a different basis with the same shape. Addedintegrate_embeddings(truecell.anchors), whichintegrate_layersnow calls;k_filter=Nonefor both methods (Seurat forcesk.filter <- NAon this path) and onlyrpcare-scales each batch (CCAIntegrationslices the object's existing, pooledscale.data;RPCAIntegrationrunsScaleDataper batch).run_pcaused sklearn's randomized SVD. It switches solvers oncemax(shape) > 500, matching only 15 of 30 PCs above |r| = 0.99 against Seurat's irlba (one PC at 0.006) — the same drift_pca_loadings(above) had already needed an exact SVD to avoid. Invisible while only leading PCs were read downstream; not invisible onceintegrate_embeddingscorrects the embedding itself. Replaced with ARPACK (scipy.sparse.linalg.svds, seeded for determinism), which matches irlba to six decimals and runs 6× faster on data this shape; small inputs still take an exact dense SVD, since ARPACK misbehaves askapproaches the matrix rank. Also fixed to matchRunPCA.default:sdev = d / sqrt(ncol(object) - 1)rather than the embedding's own SD, and no re-centring ofscale.data.
Changed — BREAKING¶
The Visium loader, aligned to Read10X_Image / Load10X_Spatial
load_visiumnow filters to in-tissue spots by default.filter_by_tissuedefaults toTrue, matching Seurat'sfilter.matrix = TRUE. On afiltered_feature_bc_matrixbundle nothing changes — those barcodes are already tissue-filtered — but on araw_feature_bc_matrixthe spot count drops from every spot on the capture area to the ones under tissue (4,992 → 2,695 on the reference slide).load_visiumnow reads the lowres image by default.image_resolutiondefaults to"lowres", matchingRead10X_Image'simage.name = "tissue_lowres_image.png". The stored array is ~11× smaller;scale_coordinates()andspot_radius()follow the stored resolution, so anything reading them in image pixels changes scale accordingly.- The image key is now
"slice1", not"spatial".obj.images["slice1"]is whatLoad10X_Spatialproduces and what every ported Seurat script indexes; it used to raiseKeyError. Override withslice_name=. get_tissue_coordinates()on aCentroids,Segmentationor FOV now returns acellcolumn alongsidex/y, with the cells still on the index — the frame R returns. Callers taking.values/.to_numpy()over the whole frame now get an object array and should select[["x", "y"]].
The previous behaviour is still available:
load_visium(path, image_resolution="hires", filter_by_tissue=False, slice_name="spatial").
Fixed¶
The Visium container, audited against Seurat 5.5.1
- The tissue image depended on which optional package was installed.
_imreadtriedmatplotlib.imageand fell back toPIL, which return float32 in [0, 1] and uint8 in [0, 255] respectively — arrays 255× apart, with different dtypes, from the same PNG. Neither library is a declared dependency, soVisiumV2.get_image()was a function of the environment as much as of the file. Plotting never revealed it becauseimshowaccepts both. Both backends now return the same float array, matchingpng::readPNGto float32 epsilon.
Two Seurat behaviours are reported and deliberately not matched: it
stores spot_diameter_fullres in the FOV's radius (truecell keeps
diameter / 2; the slide's fixed 100 µm spot pitch shows the field is a
diameter, since read as a radius the capture areas would overlap by 31 µm),
and Radius() on a VisiumV2 returns NULL because no Radius.VisiumV2
method exists. See tutorials/visium_vignette.md.
The out-of-core path, audited against Seurat 5.5.1 + BPCells 0.3.1
The out-of-core path, audited against Seurat 5.5.1 + BPCells 0.3.1
- Five functions read an on-disk layer by densifying all of it.
_log_normalize,_vst_hvg,scale_data,find_markersandadd_module_scoreeach took a dense fallback that rannp.asarray(whole)[idx]where subsetting first would have been cheap —LazyMatrix.__getitem__already returns a scipy block. The effect was that backing a matrix on disk made things worse:normalize_dataon pbmc3k used 4.6× the peak memory of the sparse path (1169 MB against 253 MB) and left a densendarray16× larger than the sparse layer it replaced (296 MB against 18.3 MB).col_blocks, documented as "the primitive for an out-of-core reduction", had no callers. Now zero whole-store materialisations across the whole pipeline. percentage_feature_setraised on an on-disk layer.sp.issparse(LazyMatrix)isFalse, so it took the dense branch — but indexing aLazyMatrixreturns scipy, whose.sum(axis=0)is a(1, n)matrix rather than a vector, and the result reachedpd.Serieswith the wrong shape.- Object construction ended laziness before any analysis ran.
create_assay5_objectransp.csc_matrix(np.asarray(matrix))on anything not already scipy, andcalc_ndensified again fornCount/nFeature. So opening a store and building an object on it — the obvious way to use the feature — was the one path that could not work. Seurat has a.CalcN.IterableMatrixfor the same reason.LazyMatrix.nnz_per_rowadded to supportmin_cellsfiltering without materialising. _loess2was chaotically dependent on sort order. On pbmc3k 85.5 % of genes share theirlog10(mean)(13,714 genes over 2,837 distinct values, largest tied run 627).np.argsortdefaults to unstable quicksort and the LOESS window was chosen by position, so members of a tied run got different neighbourhoods and different fitted values — a spread of 1.3e-3 within a single tiedx, where R'sloessgives 1.8e-15 because a fitted value is a function ofxalone. Perturbing the input by 1e-15 moved fitted values by up to 28.8 %. The fit is now evaluated once per distinctx, with distance-based neighbourhoods and a lexicographic sort. Checked against R before adoption: median |diff| vsloess0.000333 → 0.000103, and HVG overlap with Seurat 99.65 % → 99.90 % (all 6 genes it added are ones Seurat picks).- Both HVG selectors broke ties the wrong way.
argsort(v)[::-1]orders ties by descending index; R'shead(order(x, decreasing = TRUE), n)orders them ascending, and unstably at that. Nowargsort(-v, kind="stable"). - The sparse and on-disk paths were two implementations agreeing to 1e-14.
That is not enough when a tie-break consumes the statistic:
variance.standardizedcarries exact ties, and genes tied under one summation order are not tied under the other. It reordered 147 of 2000 features and produced 9 clusters against 8. Both layer types now share one block reduction, so the two paths are bit-identical by construction.
The differential-expression test suite, audited against Seurat 5.5.1
avg_log2FCput Seurat's pseudocount on the group mean instead of the group sum. Seurat 5'slog1pdata.mean.fxnislog2((sum(expm1(x)) + 1) / n), so the pseudocount is worth1/non the mean scale; truecell added a whole count to the mean, which is Seurat 4's formula. Every fold change was floored toward zero — a gene detected in 0 % of one cluster and 24 % of the other read −1.26 against Seurat's −9.92.logfc_thresholdfilters on this value, so the error changed which genes were returned, not only what they were labelled: 2,298 genes against Seurat's 11,931 at a 0.25 threshold (Jaccard 0.193). Now matches R to 7.11e-15 across all 13,712 genes.negbinomran a likelihood-ratio test on a moment-estimated dispersion where Seurat'sGLMDETestfitsMASS::glm.nb(dispersion by maximum likelihood) and takes the Wald p-value. HLA-DRA read 5.5e-128 against R's 1.1e-321. After the fix, p-values agree exactly for every gene detected above 5 %; below that the GLM is fitting near-empty rows, and Seurat'smin.cells.featuredrops those genes anyway.test_avg_log2fc_matches_seurat_formulaencoded the same wrong formula and checked truecell agreed with itself, so it was green the whole time under a name that claimed Seurat parity. Corrected.- The
latent_varsdocstring had MAST backwards — it advised passing the cellular detection rate "to match Seurat's default CDR covariate", butMASTDETestfits~ conditionalone and adds no CDR term. Passing CDR is a deliberate departure from Seurat, not a way to match it.
Spatial statistics and the spatial container, audited against Seurat 5.5.1
find_spatially_variable_features(method="moransi")used the wrong spatial weights. Seurat builds1/d²between every pair of cells andRfast2::moranIrow-standardises it; truecell used a k-nearest-neighbour graph. It was a good approximation — Pearson 0.986 against R, 46 of R's top 50 genes — which is exactly why nothing caught it, but it ran a median 1.23× high and recovered only 7 of R's top 10, the part of the ranking anyone reads. R's weighting is now the default and matches to 1.6e-14 with 10/10, evaluated in row blocks so the n × n matrix is never materialised: the full 36,602-cell slide runs in 5.3 s at 0.95 GB whereRunMoransIneeds a 10.7 GB allocation.weights="knn"keeps the old path, documented as an approximation. The p-value deliberately stays a normal approximation — R's 999-permutation test returns 14 distinct values and ties 233 of 248 genes at its floor.Centroidsnever carried a radius.SeuratObjectalways computes one (.AutoRadius, 1% of the mean bounding-box dimension — 42.83 on the Xenium mouse brain). truecell left itNone, and because_spot_collectionreturnsNonefor aNoneradius, every true-to-scale spot renderer silently fell back to a fixed-size scatter on every FOV not built from a Visiumscalefactors_json.json._spatial_panelalso read the radius off the FOV, where R keepsNULL; it now reads the default boundary, as R does.Segmentationstored polygons open. R closes each ring by repeating the first vertex — a square is five rows, not four. Now closed, idempotently, with concave shapes preserved.- The pbmc3k figure generator mislabelled every cell type. Its hardcoded
cluster→cell-type map had
1↔2and3↔4transposed, putting monocyte names on the T-cell compartment in every labelled figure, including the annotated UMAP that heads the tutorial. The R code printed beside it inpbmc3k_tutorial.mdhad the correct order throughout. Corrected in both, figures regenerated, and now guarded by a test that checks each label against its own discriminative marker instead of trusting the map.
The object model, audited against Seurat 5.5.1 (#48)
Eleven fidelity defects, found by the first tutorial to compare the container
rather than an algorithm. join_layers / split_layers had zero call sites and
zero tests before this — the defining feature of the v5 object model, never once
run.
split/JoinLayerswas not a round trip. The join returned a layer namedjoined(Seurat restores the original name) whose columns were in the split's order rather than the assay's. The assay's own cell vector never moves during a split, so the matrix came back silently misaligned against the metadata that indexes it — ask for cellc1's column, getc2's. Every shape, sum and checksum was intact, because every value was still present.join_layers()with no arguments — the only call a real script makes — also raisedValueErroron any prepared assay, hstackingcounts,dataand a variable-features-onlyscale.datatogether regardless of feature count. The fix groups layers by the stem they were split from and records that provenance at split time, because the name cannot be parsed back: Seurat's ownscale.datacontains the separator. Split parts are now namedcounts.batch1, Seurat's spelling, notcounts_batch1.truecell.generics.split_layerswas declared but never registered for any type, so the documented generic raisedNotImplementedErrorwhile the method it should have dispatched to worked fine.fetch_datareturned objects instead of numbers.np.asarrayon a sparse matrix yields a 0-d object array wrapping it, not its contents, so.flatten()broadcast onecsc_matrixdown every row — 2,700 copies of the whole matrix in place of 2,700 expression values, on the most-called accessor in Seurat and on the default assay class. Its test asserted the column name and the row count, both of which that satisfies.fetch_datacould not address an embedding column by its key.PC_1raisedKeyError; only the reduction name worked, and it emittedpca_1rather than theKey()-derivedPC_1R uses. Both now work.fetch_datareadcountswhere R readsdata, returning raw integers where every vignette shows normalized expression. It now defaults todataand, when there is nodatalayer, falls back tocountswith a warning, as Seurat does.- The command log was inert.
log_truecell_commandwas a public export with no call sites, soobj.commandswas always empty where Seurat logs one entry per pipeline step.normalize_data,find_variable_features,scale_data,run_pcaandfind_neighborsnow log, keyed as Seurat keys them (NormalizeData.RNA…FindNeighbors.RNA.pca). orig.identwas never created. It is the first column of every Seurat object's metadata and the default identity class.add_meta_datarejected a plain vector, which is the form R'sAddMetaDatadocuments and the vignettes pass it.
Leverage-score sketching: flattened sampling weights and the wrong label transfer (#46)
leverage_scorewhitened against the full rank. Seurat computes leverage from a rank-50 truncated SVD —rowSums(V²)over the leading 50 right singular vectors, so the scores sum to 50. Truecell used every direction above a tolerance, which is the classical hat-matrix definition and equally defensible in the abstract, but useless on data of this shape: the scores sum to the rank and are capped at 1, so 2000 variable features over a few thousand cells crush every score towardsd/n. On PBMC 3k that meant a max/median of 1.34 against Seurat's 6.48, where uniform sampling scores 1.00 — leverage sampling had become an expensive way to sample uniformly, silently. Both regimes are now ported: the truncated SVD belownsketch * 1.5cells, andCountSketch→QR→JLEmbedabove it, along with Seurat'snsketchbump and its "too slow" / "too square" guards. The exact regime now matches R per-cell (Spearman 1.000000, max abs diff 3.4e-6).leverage_scoreread the wrong layer. The default was"scale.data"; Seurat scores the log-normalized"data". Changed to match.sketch_datafollows.project_datatransferred labels through integration anchors. Seurat'sProjectDatacallsTransferSketchLabels— a weighted k-nearest-neighbour vote inside the projected reduction, with the sketch's own rows as the reference. The anchor route scored better on ifnb (0.936 against Seurat's 0.905), which is why it survived review; it is still wrong, and it costs precisely what sketching exists to remove, so at the scale this API targets it is unusable rather than merely different. Now matches Seurat's mechanism and its accuracy exactly (0.9050 each), at 98.1 % per-cell agreement on a shared sketch. Seurat's weight kernel is reproduced term-for-term fromFindWeightsC.sketch_datagainedmethod="Uniform", as in Seurat — the control that makes "the sketch keeps rare cells" a meaningful claim.
Breaking: project_data no longer accepts seed (the k-NN vote is
deterministic), and no longer accepts a raw label array for refdata — like
Seurat it takes a column name on the sketch, or {new_col: sketch_col}.
leverage_score's eps changed meaning from an SVD rank tolerance (1e-8) to
Seurat's Johnson–Lindenstrauss distortion (0.5), and it gained ndims.
JackStraw: a mis-specified permutation null and the wrong significance test (#45)
jack_strawbuilt its null against a fixed basis. R'sJackRandompermutes the selected features and re-runs the whole PCA, taking the null loadings from that refit basis; truecell projected the permuted rows onto the existing embedding. A fixed basis cannot rotate to absorb the scrambled signal, so the permuted loadings came out too small and the null was far too tight. On pbmc3k's pure-noise PCs 14-20 that put 109-203 of 2000 features below p ≤ 1e-5, where R finds 0-5. Now refits the PCA per replicate, as R does.score_jackstrawused a KS test instead ofprop.test. R'sScoreJackStrawtests the count of features belowscore.threshagainst the count expected under a uniform null; truecell ran a one-sided Kolmogorov-Smirnov test against Uniform(0, 1), which on thousands of features is enormously more sensitive — its largest score across all 20 pbmc3k PCs was8.1e-112, so no PC ever failed the threshold. R'sprop.testis now ported exactly (Yates-corrected two-sample chi-square), reproducing R to nine significant figures from 1e-143 to 1.0.- Net effect: truecell recommended keeping all 20 PCs where Seurat keeps 13 — the function could not do the one thing it exists for. Both now keep 13. The remaining spread is permutation scatter (13-15 across seeds; R fixes each replicate's seed to its loop index and is therefore deterministic).
JackStrawData.fake_reduction_scoresis now populated, as in R;jack_strawtakes the reduction's stored feature loadings as the observed statistic (matchingLoadings(object[[reduction]], projected = FALSE)) and raises if they are absent, rather than silently re-deriving them.- Both defects were caught by the new R side-by-side, not by the test suite, which was green throughout: its only JackStraw assertion was that signal features score lower than noise features, which stayed true the whole time. Regression tests now pin the null's calibration on noise PCs and the aggregation's ability to reject, and both were mutation-tested against the old code.
Added¶
Differential-expression tutorial — Wave 3's first
tutorials/pbmc3k_de_tutorial.py+pbmc3k_de_verify.R+de_vignette.md+figures_de/. All eightfind_markerstests againstFindMarkerson pbmc3k clusters 0 vs 1, on a cell assignment exported from the Python side so that no clustering difference can be mistaken for a DE difference. Eight tests, none of which had ever been compared to R.- After the two fixes, all seven per-cell tests reproduce Seurat's top 50 genes
exactly, with
wilcox/t/bimod/LRat p-value Spearman ≥ 0.99997 andmastat 0.9993 on genes detected above 5 %. - Reported rather than changed:
deseq2is pseudobulk where Seurat tests cells as replicates (and requiressample_col, so it raises rather than silently substituting); Seurat roundsmyAUCto 3 dp insideDifferentialAUC; R'swilcoxreturnsNaNfor genes expressed in neither group where truecell returnsp = 1.
Spatial-statistics tutorial — Wave 2's last, and the wave's close
tutorials/xenium_svf_tutorial.py+xenium_svf_verify.R+svf_vignette.md+figures_svf/. Compares the spatial container (load_xenium/create_fov/create_centroids/create_segmentationagainstLoadXenium/CreateFOV/CreateCentroids/CreateSegmentation) and the one spatial statistic never checked against R,find_spatially_variable_features. The existing Xenium tutorial built its R side fromRead10Xplus a coordinate frame, so R never constructed an FOV and the whole boundary layer had gone uncompared.- 38 of 39 anchors match Seurat exactly, 32 of them with no tolerance at all.
The one that differs is
GetTissueCoordinates' shape — R returnsx, y, cellas three columns, truecell carries the cell as the DataFrame index. weights=onfind_spatially_variable_features:"inverse_square"(R's, the new default) or"knn"(the previous behaviour, kept for very large slides).dot_plotfolded into the pbmc3k gallery as08b_marker_dotplot.png— the last plotting export with no tutorial coverage. Drawing it is what exposed the cluster-label transposition fixed above.- Wave 2 is complete: five tutorials, sixteen defects, against Wave 1's four and two.
Object-internals tutorial — the container, side by side with Seurat (#48)
tutorials/pbmc3k_objects_tutorial.py+pbmc3k_objects_verify.R+objects_vignette.md+figures_objects/. The first tutorial to compare the object model rather than an algorithm:Cells/Features, the layered assay,Key,Embeddings/Loadings/Stdev,Graphs,FetchData,Idents/WhichCells/RenameIdents/subset, and the command log.- Nothing in it is stochastic, so 89 of 91 anchors are compared with no tolerance at all — orders, names, dimensions, keys and non-zero counts either match Seurat or they do not. The two exceptions are the fields that read a PCA, named explicitly rather than covered by a blanket rule.
- 43 tests: 25 on the tutorial's own helpers (including the comparison instrument itself — one that always agrees would make every number it prints worthless) and 18 regressions on the defects above. All mutation-tested.
- Tutorial coverage of public exports: 36/103 → 81/104.
Guards against supported-Python drift (#47)
- Three tests in
tests/test_packaging.pycross-checking the four places the supported-version decision is written down —requires-python, the trove classifiers,[tool.ruff] target-version, and the CI matrix. Nothing read those together before, and each is quiet when wrong in a different way: a stale classifier misinforms PyPI without breaking a build; a matrix that has moved above the declared floor stops testing the floor, which is the version most likely to break; a ruff target below the floor silently disables the lint the floor just earned. - Mutation-tested in all four directions — drop a classifier, raise the floor, revert the ruff target, drop the lowest matrix leg — each caught by the specific guard(s) it should be, none firing indiscriminately.
- The matrix is parsed from the workflow YAML with a regex rather than PyYAML: PyYAML is not a declared dependency, so importing it would pass today and begin silently skipping the day that transitive edge disappears — the exact drift these tests exist to catch.
Leverage-score sketching tutorial — side by side with R Seurat (#46)
tutorials/sketch_vignette.mdwithifnb_sketch_tutorial.py,ifnb_sketch_verify.R, andgenerate_sketch_plots.py—leverage_score(LeverageScore),sketch_data(SketchData) andproject_data(ProjectData) on ifnb, on a cell and feature basis shared with the R run.- First real-data fidelity result for all three (synthetic fixtures only
before), and it found the two defects above. Exercises both of Seurat's
regimes on one dataset by moving
nsketchrather than the data. Headline: exact-regime Spearman 1.000000 against R, leverage tracks cell-type rarity at Spearman −0.929 in both tools (CD4 Naive T 0.76× → Eryth 2.89×), andproject_datamatches Seurat's label accuracy exactly. - ifnb's 13 annotated types — 4,362 cells down to 55 — make the payoff directly measurable, against a same-size uniform sketch as the control. No synthetic fixture reproduces it: several were tried, and R agrees with truecell on those to 1e-5 while showing no enrichment either, because real rare types are transcriptionally extreme rather than merely scarce.
- The lazy on-disk
LazyMatrixround-trip is checked too, but reported separately and not as a side-by-side — R's equivalent is BPCells, which is not installed here.
Dimensional-reduction extras tutorial — side by side with R Seurat (#45)
tutorials/dimreduc_vignette.mdwithpbmc3k_dimreduc_tutorial.py,pbmc3k_dimreduc_verify.R, andgenerate_dimreduc_plots.py—jack_straw/score_jackstraw(JackStraw/ScoreJackStraw),run_ica(RunICA) andrun_tsne(RunTSNE) on PBMC 3k, on a cell and feature basis shared byte-for-byte with the R run.- First real-data fidelity result for all four (synthetic fixtures only before).
After the JackStraw fixes above, both tools keep 13 PCs. ICA recovers the same
subspace — components are matched one-to-one by |Pearson r| with the Hungarian
algorithm, since they are defined only up to sign and order, giving 0.982 mean
matched |r|. t-SNE is compared on structure rather than coordinates (
Rtsneis Barnes-Hut, truecell calls scikit-learn): each embedding retains 0.470 / 0.477 of its PCA neighbourhoods. - The comparison reports where the two PCA bases stop matching in order (PC 15 on pbmc3k) rather than a bare minimum correlation, so a permuted noise tail is not mistaken for a disagreeing basis — and so a per-PC finding is only claimed over the range where it is like-for-like.
Cell-cycle & module-score tutorial — side by side with R Seurat (#44)
tutorials/cellcycle_vignette.mdwiththp1_cellcycle_tutorial.py,thp1_cellcycle_verify.R, andgenerate_cellcycle_plots.py—add_module_score(AddModuleScore) andcell_cycle_scoring(CellCycleScoring) on the proliferating THP-1 line (GSE153056), compared against Seurat on identical GEO counts and the same resolved S / G2M / module gene lists. Opens Wave 2 of the tutorial initiative.- First real-data fidelity result for the scoring features (synthetic fixtures
only before): per-cell
Phaseconcordance with Seurat is 96.62 % (20,028 of 20,729 cells), and theS.Score/G2M.Score/ module scores correlate at Pearson ≥ 0.998. Both functions sample control genes at random and NumPy's RNG is not R's, so the scores are not bit-identical by construction — the residual is that control-gene RNG (the discretePhaseis robust to it), the same documented behaviour asclara(hashing) and the MULTI-seq KDE. No defect found. - 11 network-free unit tests (
tests/test_cellcycle_tutorial.py) covering the metric helpers and a synthetic run with planted S/G2M populations, plus a gated real-data regression intests/test_tutorial_smoke.py.
Reference mapping tutorial — label transfer, side by side with R Seurat (#43)
tutorials/refmap_vignette.mdwithpanc8_reference_mapping_tutorial.py,panc8_reference_mapping_verify.R, andgenerate_refmap_plots.py— the reference-mapping workflow (find_transfer_anchors/transfer_data/map_query/project_umap) on the panc8 pancreatic-islet atlas (Baron et al. 2016), annotating a SMART-seq2 query from a CEL-seq2 reference. Both tools read identical exported counts and a shared variable-feature basis; the query's truecelltypeis held back as ground truth so the transfer is scored for accuracy, not just agreement with R. A single-technology reference is used deliberately, to isolate the transfer machinery from the integration path.- First real-data fidelity result for the reference-mapping features (only
synthetic two-type fixtures before): per-cell label concordance with Seurat is
98.71 % (2,363 of 2,394 query cells get the same
predicted.id), and each tool is ~98.5 % accurate against the held-out cell types (truecell 0.9845, Seurat 0.9879). Every abundant cell type is recovered at ≥98 %; the rare types (<10 reference cells) are noisy in both tools alike — a small single-tech reference's honest limit, not a divergence. No defect found — the transfer stack ports faithfully. Completes Wave 1 of the tutorial initiative. - 12 network-free unit tests (
tests/test_refmap_tutorial.py) covering the metric helpers and a synthetic two-technology end-to-end run, plus a gated real-data accuracy regression intests/test_tutorial_smoke.py.
Integration tutorial — Harmony / CCA / RPCA, side by side with R Seurat (#41)
tutorials/integration_vignette.mdwithifnb_integration_tutorial.py,ifnb_integration_verify.R, andgenerate_integration_plots.py— the three batch-integration paths (run_harmony/integrate_layers(method="cca"|"rpca")) on the ifnb IFN-β benchmark (Kang et al. 2018), compared against Seurat on identical exported counts and a shared variable-feature basis. The concordance is partition-based (cluster ARI, cell-type-recovery ARI, batch-mixing entropy) since integration embeddings are not coordinate-comparable across tools.- First real-data fidelity result for the integration features (v0.2.0; only synthetic-fixture tests before): Harmony and CCA reproduce Seurat's batch mixing and cell-type recovery to three decimals (batch-mixing entropy py/R 0.991 and 0.990/0.991). The first tutorial in the initiative to find real defects — two RPCA bugs (see Fixed): a crash on unequal batch sizes, and a ~4× under-integration; both are now fixed (the under-integration in follow-up #42).
- Added to the opt-in tutorial smoke suite (
TRUECELL_TUTORIAL_SMOKE=1) and covered bytests/test_integration_tutorial.py(network-free: the silhouette/ARI/entropy helpers and the load→prep→integrate→score→concordance path on a synthetic two-condition dataset with unequal batch sizes). Suite 507 → 522. No newpipdependencies (the R reference uses the already-listedharmonypackage).
Mixscape tutorial — CalcPerturbSig + RunMixscape + MixscapeLDA, side by side with R Seurat (#40)
tutorials/mixscape_vignette.mdwiththp1_mixscape_tutorial.py,thp1_mixscape_verify.R, andgenerate_mixscape_plots.py— the pooled-CRISPR Mixscape workflow (calc_perturb_sig/run_mixscape/mixscape_lda) on the THP-1 ECCITE-seq screen (GSE153056), compared call-for-call against Seurat on the same GEO bytes and a shared variable-feature basis.- First real-data fidelity result for the Mixscape features (all of which
landed after PR #10 with only synthetic-fixture tests): per-cell class
concordance is 97.45 % for both the global class (KO/NP/NT) and the full
<gene> KO/NPclass. All NT cells agree, the same 14 guides read zero-effect on both sides, and the strong interferon-γ hits agree ≥97 %; the residual is isolated to the weak boundary guides (MYC/SPI1/BRD4/CUL3) where the EM mixture is init-sensitive — a genuine method-level difference (scipyGaussianMixturevs Rmixtools, plus per-gene DE tie-breaking), documented in the walkthrough. No defect found on a far more stochastic pipeline than the hashing demuxers. - Added to the opt-in tutorial smoke suite (
TRUECELL_TUTORIAL_SMOKE=1) and covered bytests/test_mixscape_tutorial.py(network-free: the perturbation-table / concordance helpers and the load→signature→classify→LDA path on a synthetic screen with known KO truth). Suite 496 → 507. No newpipdependencies (the R reference adds themixtoolsCRAN package forRunMixscape).
Cell-hashing tutorial — HTODemux + MULTIseqDemux, side by side with R Seurat (#39)
tutorials/hashing_vignette.mdwithpbmc_hashing_tutorial.py,pbmc_hashing_verify.R, andgenerate_hashing_plots.py— demultiplexing the 8-hashtag Cell-Hashing dataset (GSE108313) withhto_demux/multiseq_demux, compared call-for-call against Seurat on byte-identical GEO input.- First real-data fidelity result for the hashing features (all of which
landed after PR #10 with only synthetic-fixture tests):
HTODemuxis 99.81 % call-concordant with Seurat for both the global class and the sample assignment — confirming the CLR-margin fix (#32) and theclaradefault (#34).MULTIseqDemuxagrees on 94.67 %; the residual is a genuine KDE-implementation difference (scipygaussian_kdevs Rdensity()— bandwidth and grid, which a singlenrd0swap makes worse, not better), documented in the walkthrough. - Added to the opt-in tutorial smoke suite (
TRUECELL_TUTORIAL_SMOKE=1) and covered bytests/test_hashing_tutorial.py(network-free: species/concordance helpers and the load→demux→figure path on a synthetic barnyard). Suite 489 → 496. No newpipdependencies.
Tutorial data infrastructure — the R-side scaffolding for expanding tutorial coverage (#38)
truecell.datasets.pbmc_hashing(GSE108313) andthp1_eccite(GSE153056) — loaders for the Cell-Hashing and ECCITE-seq/Mixscape datasets, parsed straight from their original GEO plain-text files so R and Python read identical counts. The ECCITE loader also returns the per-cell guide/replicate metadata, so a Mixscape tutorial can start from the same annotated state as R'sthp1.eccite.truecell.datasets.ifnbandpanc8, withtutorials/export_seuratdata.R— a one-time R bridge that exports the curated SeuratData objects (which have no clean cross-language raw source) to a gzipped 10x folder thatread_10xreads. Verified end to end: R exported panc8 and Python read back 51,767,089 nonzeros — matching R to the entry — with barcodes and metadata aligned.- No new
pipdependencies (the loaders are pure pandas/scipy). R side addsSeuratData+harmony. Very wide count tables are parsed once and memoised to a.npzsidecar, so a repeat load is ~0.2s rather than minutes.
Reference mapping and label transfer (milestone v0.3.0)
find_transfer_anchorsandtransfer_data— atlas-based annotation withpcaproject/ccareduction and both classification and imputation. (#22)map_queryandproject_umap— place a query dataset in the reference's existing UMAP. (#23)
Integration (milestone v0.2.0, completing it)
integrate_dataandintegrate_layers— CCA/RPCA anchor-based integration, alongside the Harmony path released in 0.2.0. (#21)
Dimensionality reduction (milestone v0.5.0)
run_spca— supervised PCA. (#19)glm_pca— GLM-PCA with Poisson (#19) and negative-binomial (#20) families. Pure NumPy/SciPy; theglmpca-pydependency proved unnecessary.
Pseudobulk and differential expression (milestone v0.6.0)
aggregate_expressionandfind_conserved_markers. (#10)find_markers(test_use="deseq2")— pseudobulk DESeq2 via optionalpydeseq2. (#11)find_markers(test_use="mast")— MAST two-part hurdle test. (#12)find_markers(test_use="bimod")— the McDavid 2013 likelihood-ratio test. (#13)
Spatial transcriptomics (milestone v0.7.0)
load_merscope— Vizgen MERSCOPE loader. (#14)find_spatially_variable_features— Moran's I (#15) and markvariogram (#18).VisiumV2andload_visium(image=)— the tissue-image data layer. (#16)spatial_dim_plotandspatial_feature_plot— H&E overlays. (#17)
Scale and performance (milestone v0.8.0)
sketch_data,project_data, andleverage_score— leverage-weighted subsampling for million-cell datasets. (#24)LazyMatrix— BPCells-style on-disk matrices built on NumPy memory-mapping, no new dependency. (#25)
Specialized assays (milestone v0.9.0)
hto_demux—HTODemuxcell-hashing demultiplexing. (#26)multiseq_demux— MULTI-seq demultiplexing. (#27)calc_perturb_sigandrun_mixscape— pooled-CRISPR analysis. (#28)mixscape_lda— supervised map separating guide populations. (#29)plot_perturb_scoreandmixscape_heatmap. (#30)truecell._claraandhto_demux(kfunc="clara")— an in-tree port of R'scluster::clarak-medoids, which is whatHTODemuxactually uses. Needs no sklearn. (#33)
Known caveat: R's clara is not reproducible across CPU architectures —
it accepts swaps on any improvement below zero, so one ulp in one distance
flips the whole clustering, and clara.c fuses a multiply-add on arm64 that
it rounds twice on x86-64. "Match R" is therefore not well-defined. This port
deliberately follows plain IEEE double arithmetic (= clara.c on x86-64,
and what NumPy gives everywhere), and is exact against that reference. It can
disagree with an arm64 R build, by design.
Changed¶
- Supported Python is now 3.12–3.13; 3.10 and 3.11 are dropped. The CI matrix
moves with it, and
requires-pythonbecomes>=3.12.
The floor tracks SPEC 0 — the three-years-past-release window numpy, scipy, pandas and scikit-learn keep for themselves — rather than CPython's five-year EOL. By that rule 3.10 lapsed in Oct 2024 and 3.11 in Oct 2025, both already past; going by EOL alone would have held 3.11 until Oct 2027. Those four packages are what actually constrain this library, so theirs is the calendar worth following.
The full suite passes identically on both legs — 616 passed / 17 skipped — as do all 17 tutorial smoke tests, which CI skips on every leg, run here against the real datasets.
Python 3.14 is not included, though it very nearly works. Every package in
the set has a cp314 manylinux wheel except harmonypy, which publishes
cp39–cp313 only. Without a wheel, uv builds it from source, and that needs BLAS
plus a CMake-fetched armadillo ubuntu-latest does not have. Forcing a
wheels-only resolve is worse: it backtracks to harmonypy 0.2.0, which depends
on torch and pulls in triton and 24 nvidia-* packages. Dropping the
integration extra on a 3.14 leg does resolve clean (95 packages, wheels only)
but would leave 18 harmony tests unrun there. Deferred until harmonypy ships
a cp314 wheel; see ROADMAP.md.
Nothing breaks retroactively. pip on 3.10 or 3.11 resolves to 0.2.0, the
last release declaring >=3.10.
Also removed: the tomli dev dependency (tomllib is stdlib from 3.11) and
its import fallback in tests/test_packaging.py — the only version-gated code
in the repo. [tool.ruff] target-version moves to py312.
hto_demuxnow defaults tokfunc="clara", matching Seurat; it first shipped defaulting to"kmeans". Callers who never passedkfuncget different output: ~1% of cells change class on synthetic panels, rising with tag count to ~3.5% at 12 tags — whereclarais also the more accurate of the two. Accuracy is otherwise a wash, so this is a fidelity change, not a quality one."kmeans"remains available. Both scale linearly in cells;claracosts a roughly constant 4× (~1.3 s vs ~0.3 s at 100k cells), so choose on fidelity rather than speed. (#34)find_multi_modal_neighborsis now a full two-stage port of Seurat's WNN (FindModalityWeights+MultiModalNN), replacing an approximation that used a linear distance ratio and blended per-modality SNN graphs instead of doing a joint neighbour search. The old formula was monotone in the right quantity but had no dynamic range, pinning every weight near 0.5 — a weight stuck at 0.5 cannot say "this cell is decided by protein", which is the one thing WNN exists to say. On synthetic data where one group separates only in RNA and another only in ADT, the port now gives them ADT weights of 0.073 and 0.993 (previously 0.482 and 0.575). (#31)hto_demuxandmultiseq_demuxdefault tomargin=1instead ofmargin=2. Their behaviour is unchanged — this compensates for the_clr_normalizefix below, which would otherwise have silently broken both. Note thatmargin=1for hashing is deliberate and correct despite Seurat's ADT advice beingmargin=2: hashing wants per-hashtag-across-cells, which is what Seurat's hashing vignette does at its own default. (#32)
Fixed¶
-
integrate_layers(method="rpca")under-integrated ~4× versus Seurat's RPCA on real data — on the ifnb benchmark it reached batch-mixing entropy 0.222 against Seurat's 0.914, with cell-type recovery (0.444) below the uncorrected baseline, whilereduction="cca"andrun_harmonymatched Seurat to three decimals. Reading the real Seurat source (ReciprocalProject,FindNN) against an anchor-count probe showed the reciprocal-PCA path diverging three ways: it scaled the batches globally instead of per-object (Seurat'sSplitObject → ScaleDataper object), leaving each batch's mean shift in PC1 so reciprocal PCA under-found mutual pairs; it searched the raw projection instead of Seurat'sl2.norm(standardise each dimension by its SD, then L2-normalise each cell), so PC1's variance swamped the neighbour search; and it applied the expression-space anchor filter Seurat disables for reciprocal PCA. Fixing all three lifts RPCA batch-mixing to 0.867 and cell-type recovery to 0.677 (now above baseline), with CCA and Harmony unchanged. The residual to 0.914 is the expected exact-vs-annoy-neighbour / scikit-learn-vs-irlba-PCA gap. Regression tests: a unit test of the embedding normalisation, a check that the RPCA weight embedding is L2-normalised (the fix's observable signature — pre-fix rows were 0.79–0.92), and a gated ifnb batch-mixing floor, since the emergent under-integration reproduces on no synthetic fixture (both a 3-type and a hard 6-type unequal-batch fixture integrate fine on the pre-fix code). Completes the RPCA pair found in #41. (#42) -
find_integration_anchors(reduction="rpca")crashed withIndexErroron any pair of datasets with unequal cell counts — i.e. every real dataset (the ifnb benchmark is CTRL 6,548 vs STIM 7,451). The reciprocal-PCA branch passed its mutual-nearest-neighbour helper the reference/query projections in the wrong order, so the query-neighbour list was sized to the reference and indexed past its end whenever the query was larger. Balanced synthetic fixtures (equal batch sizes) never tripped it. Fixed by restoring the argument order, with two regression tests over unequal-size batches (both orderings). Found while building the ifnb integration tutorial (#41). A separate, deeper RPCA under-integration (~4× vs Seurat's RPCA) was found at the same time and is fixed in #42 — see the entry above;reduction="cca"andrun_harmonywere unaffected throughout. -
BREAKING —
normalize_data's CLRmarginargument was inverted relative to Seurat.margin=1is now per-feature across cells (Seurat's default) andmargin=2is per-cell across features (what ADT panels want), matching the axis R'sCustomNormalizepasses toapply(data, MARGIN = margin, ...). Verified against R: truecellmargin=2reproduced Seuratmargin=1and vice versa, agreeing to 5e-6. Only the axis was wrong — the per-vector kernel was always exact.
Who is affected: callers passing margin explicitly to normalize_data,
hto_demux, or multiseq_demux. Callers using the defaults are unaffected.
This was the sole cause of the CBMC tutorial's ADT.weight gap against
Seurat; eight of nine cell types now match to 0.02 or better. (#32)
sctransform's regularized NB model was wrong in four places, and the errors compounded into a normalization that erased the fine cell subsets SCTransform exists to resolve. A method-of-moments estimator stood in fortheta.ml; the regularization was smoothed against the arithmetic gene mean where R uses the geometric mean, and targetedlog(theta)rather than the overdispersion factor; and residual variance — which ranks the variable features — was computed from residuals clipped atsqrt(N/30)where Seurat clips atsqrt(N), applying the tighter clip only to the storedscale.data. Verified against a live Seurat 5.5.1 / sctransform 0.4.3 run on PBMC 3k, the regularized intercept now matches R at Spearman 1.0000, theta at 0.96, and residual variance at 0.9986, with 2,913 of 3,000 variable features shared — previously those were 1.0000, −0.89, −0.07 and 414 of 3,000. (#37)
What it looked like: the SCTransform tutorial resolved 9 clusters against R's 12 — and, the real tell, fewer than the 11 from plain log-normalization, inverting the vignette's whole claim that SCTransform resolves finer subsets. It now resolves 13 with 4 T-cell subsets against log-normalization's 11 and 2, recovering the pDC, CD8 naive/memory and interferon-response populations. The Poisson GLM was never at fault — its intercept and slope always matched R exactly; only what was built on top of it was wrong.
Also: sctransform now takes vst_flavor, defaulting to "v2" as Seurat 5
does (depth slope fixed at log(10), non-overdispersed genes modelled as pure
Poisson, a variance floor), with "v1" for the original 2019 model. Under
"v1" Python and R both resolve 13 clusters; at the "v2" default Truecell
resolves 13 to R's 12, a real one-cluster difference (R is stable at 12 across
seeds) left by vst's random step-1 gene sample and the different clustering
libraries. The assay's meta_data now also carries gmean.
Why it went unseen: nothing compared the model against R. The tutorial's
cluster count was documented as an expected implementation difference and
carried a ⚠️ in sctransform_vignette.md, which made a real defect look like a
known caveat — and tutorials/README.md claimed an "exact match" on the 3,000
variable features when 13.8% of them agreed. tests/test_sctransform_r_fidelity.py
now pins each numerical primitive against R directly, including a port of
bw.SJ (Sheather–Jones; SciPy has no equivalent) that matches R to 3e-7.
tutorials/pbmc3k_tutorial.py— the tutorial the README sends new users to first — crashed withKeyError: 'cluster'on every fresh install. pandas 3 stopped passing the grouping column into the callable ofgroupby(...).apply(...), so the top-markers table came back without the column the next line filtered on. It now builds the table per cluster and runs on pandas 2.0 through 3.x. (#36)
Why it went unseen: the old code works on pandas 2, pyproject declares
pandas>=2.0, and no test executed a tutorial — so a developer venv holding
pandas 2 passed while a fresh pip install resolved pandas 3 and broke. The
full suite passed on the very install where the tutorial died. Tutorials now
have execution coverage: tests/test_tutorial_marker_tables.py runs in CI, and
tests/test_tutorial_smoke.py runs each tutorial end-to-end behind
TRUECELL_TUTORIAL_SMOKE=1.
Note for anyone touching the plot generators: they use the same
groupby(...).apply(...) idiom and were not affected — they never read the
dropped column. They were rewritten to match anyway, preserving output exactly.
The obvious rewrite (sort_values(...).groupby(...).head(n)) is wrong there: it
returns the same genes interleaved across clusters, silently scrambling
DoHeatmap's per-cluster blocks. test_top_genes_is_cluster_major pins it.
Documentation¶
- CBMC CITE-seq tutorial: Step 8's WNN section written against real figures for the first time, which is what exposed both the WNN approximation and the CLR margin bug. (#31, #32)
- Side-by-side R Seurat plots added to the PBMC 8k and CBMC tutorials, and misleading R/Python figure comparisons corrected. (#8, #9)
0.2.0 - 2026-07-05¶
First release with batch correction, and the last release to date.
Added¶
run_harmonyandintegrate_layers— Harmony batch correction. (#6)find_multi_modal_neighborsandrun_umap(graph=)— WNN. (#6) Superseded: see the full WNN port under Unreleased.run_icaandrun_tsne— additional reductions. (#6)
Fixed¶
- Spatial tutorial figures are written next to the script rather than into the working directory, so the tutorial is safe to run standalone. (#5)
- README links use absolute GitHub URLs, so they resolve on the PyPI page. (#7)
0.1.1 - 2026-07-04¶
First release published to PyPI — pip install truecell works from here on.
Added¶
- Spatial Seurat parity: loaders, neighborhood analysis, niches, and composition. (#4)
- Xenium spatial tutorial, verified against R Seurat to 8 significant figures. (#4)
ROADMAP.md— the milestone plan. (#2)truecell/py.typed— the package ships PEP 561 type information, so a downstreammypyreads truecell's annotations rather than treating it as untyped. (#4)
Fixed¶
- Pin
numba>=0.59so the[analysis]and[all]extras resolve on Python 3.10+. (#2) - The README Quick Start example now produces the 500 cells it claims. (#3)
0.1.0 - 2026-06-30¶
Initial release: a port of Seurat's core data structures and analysis pipeline.
Tagged but never published to PyPI; 0.1.1 was the first release on PyPI.
Added¶
- Core objects:
Truecell,Assay,Assay5,StdAssay,DimReduc,Graph,Neighbor,LogMap,JackStrawData,TruecellCommand. - Pipeline:
normalize_data,find_variable_features,scale_data,percentage_feature_set,run_pca,find_neighbors,find_clusters,run_umap,find_markers. sctransform,module_score,jack_straw.- Spatial primitives:
FOV,Centroids,Segmentation,Molecules. - Plotting, I/O, AnnData interop, and the bundled example datasets.