Chromosome-ordered Fourier spectral analysis of tissue level transcriptomes.
Gene expression is read as a signal along the chromosome, ordered by genomic position, and decomposed into periodic components. The question is whether transcription carries positional structure at scales of tens to hundreds of genes, and whether that structure changes with disease stage.
The model, the estimators and the limits of each claim are written up in THEORY.md.
make test && ./tsf selfcheck # 335 checks + the pipeline on a known answer
Rscript scripts/calibrate_null.R # the negative control: red noise, no peak
./scripts/run_all.sh # the three axes, end to end (see below)run_all.sh is the local driver and must be committed alongside the config
it runs, or the run it produced cannot be reproduced. It encodes the ordering constraints that a list of commands does not:
maxt before condition or the Stouffer evidence is dropped, window after
stability or there are no stable peaks to place, and the two-pass ingest
the base-pair axis needs.
| axis | coverage | period means | classify queries? |
|---|---|---|---|
gene (default) |
~70% | a rank distance, not a genomic one | yes |
bp250 |
~46% | megabases, directly | no |
bp100 |
~26% | megabases, directly | no |
The gene axis is the only one that can classify a new sample, because coverage
bands cannot be calibrated faithfully on binned data — the mask would simulate
losing whole bins while a real query loses genes inside them, and a bin's value
is an aggregate that sinh() does not invert (measured ratio 0.03). Queries on
a base-pair axis come back UNCALIBRATED_COVERAGE, which means no threshold
exists for that coverage, not no match.
The base-pair axes are for differential and consensus, which report
distances rather than decisions.
With --grid-axis bp several genes are aggregated per bin, but
filter_expressed() picks them with rowMeans over each cohort's samples: every
cohort keeps a different set and the same bin is built from different genes. A
single-sample query cannot reproduce that filter at all.
./tsf ingest ... --grid-axis bp --force # writes retained_genes.tsv
Rscript scripts/shared_gene_mask.R --interim-dir ... # the intersection
./tsf ingest ... --gene-mask <file> --force # re-aggregate with itstage_reference aborts if this was not done. The third step is a full
re-ingest, not a patch: the bins have to be rebuilt, and with them the spectra,
the validation and the centroids.
Three things make the result trustworthy rather than merely computable:
- the spectral axis is the annotation grid, not the genes that survived a
filter, so
Nmeans the same thing in every dataset - unmeasured genes stay unobserved, never zero-filled, and the spectrum is fitted by least squares over the observed positions only
- every peak is reported next to its spectral window: what the pattern of missing genes alone can produce
The pipeline is layered: everything dataset-specific lives in a config file and everything else is shared code, so adding a cohort means writing one config rather than copying an analysis. Stages communicate only through files on disk, never through session state, so any stage can be re-run on its own.
A fresh clone runs without configuration. Every default path in
config/project.R is relative to the repository, and all of them are
gitignored:
git clone https://github.com/Danpc11/TissueSpectF && cd TissueSpectF
make test # base R only, no Bioconductor, no install step
./tsf selfcheck # synthetic data, known answer, needs no paths of its own
# Every command that touches your data states where it reads and writes.
# config/project.R names no paths, so there is no default to inherit.
export TSF_GEO_DIR=data
export TSF_INTERIM_DIR=interim
export TSF_RESULTS_DIR=run_2026_09_02
./tsf fetch # the five GEO cohorts into $TSF_GEO_DIR
./tsf check # confirm every input is where the configs expect
./tsf runThe three paths can equally be given per command as --geo-dir,
--interim-dir and --results-dir, which take precedence. There is
deliberately no default: a run's output location should be visible in the
command that produced it, not decided by a file nobody read.
Point the outputs elsewhere with TSF_ROOT, with TSF_GEO_DIR /
TSF_INTERIM_DIR / TSF_RESULTS_DIR, or with --geo-dir / --interim-dir /
--results-dir. Nothing has to be edited to run on a cluster:
TSF_ROOT=/scratch/$USER/TissueSpectF ./tsf runtsf the command line entry point
README.md
THEORY.md the model, the estimators, what each test licenses
PIPELINE.md the full run, command by command, on three axes
LICENSE CC BY-NC 4.0
requirements-ml.txt dependencies of the learned layer (optional)
requirements-sonify.txt dependencies of the sonification (optional)
TissueSpectF_colab.ipynb the whole pipeline on a free Colab VM
Makefile
.github/workflows/
tests.yml every test suite + self-check on every push
release.yml builds and publishes the app bundle on a version tag
config/
project.R paths, filters, and every tunable parameter
datasets/<GSE>.R one per dataset: file names, tissue, label rules
vocabularies/<name>.R condition vocabularies (levels, states, roles, order)
autoencoder.yaml the learned layer's parameters -- EARLY, see below
R/
utils_io.R logging, TSV I/O, phenotype cleaning, manifests
config.R config and vocabulary loading, validation
labels.R label harmonisation engine
fetch.R download the GEO inputs
ingest.R GEO -> common format
paths.R where each stage reads and writes
grid.R reference gene grid + least-squares (Lomb-Scargle) spectrum
spectrum.R spectra per sample and per condition
maxt.R per-sample permutation test
condition_test.R condition-level significance (permutation + Stouffer)
consensus.R characteristic spectrum of a condition (power, prevalence, PLV)
clean.R CLEAN deflation with an extended-BIC stopping rule
stability.R which peaks go downstream, and by which criterion
peaks_genes.R gene-level reconstruction of a component
compare.R signature, transitions, cross-cohort replication
fingerprint.R a sample's spectrum as a comparable feature vector
reference.R reference library, out-of-cohort validation, matching
bundle.R package the app + reference into a portable folder
stages.R each stage as a callable function
ml/ TissueSpect-AE, the learned layer (Python)
utils.py seeds, device, manifest and compatibility checks
dataset.py spectra -> masked tensor, normalisation
splits.py leave-one-cohort-out, leak checks, balanced sampling
prototypes.py cohort-balanced class prototypes
evaluate.py metrics, silhouette, cohort predictability
baselines.py the bar the model has to clear
scripts/
tsf.R CLI implementation
00_check_inputs.R verify the GEO inputs are where the configs expect
inspect_series_matrix.R read a GEO series before writing rules for it
prepare_ae_data.R export spectra for the learned layer
run_baselines.py leave-one-cohort-out baselines
match_query.R the match command
selfcheck.R end-to-end run on synthetic data with a known peak
clean_results.R empty results_dir, with a guard on what it may delete
sonify_tissuespectf.py condition spectra -> MIDI
app/
app.R local desktop app (the only part that needs shiny)
tests/
test_labels.R label engine, configs, filters, bundle
test_spectrum.R grid, GLS, maxT, CLEAN, condition test, Wilson
ml/ the learned layer (pytest, numpy/sklearn only)
config/project.R names three directories and defaults all three to the
repository, so a clone runs with no configuration:
| default | holds | |
|---|---|---|
geo_dir |
<repo>/data |
raw GEO downloads and the annotation |
interim_dir |
<repo>/interim |
the common format, one folder per cohort |
results_dir |
<repo>/results |
every spectral output |
All three are gitignored: they hold downloads and derived output, never inputs
that need versioning. No default names a particular machine — a default pointing
at one person's scratch directory makes a fresh clone fail with paths the user
has never seen, and makes a config fingerprint record a location instead of a
choice. tests/test_labels.R asserts this rather than leaving it to review.
Four ways to point them elsewhere, highest precedence first:
./tsf run --results-dir results_other # one flag, one invocation
./tsf run --config config/other_tree.R # a named set of settings
TSF_RESULTS_DIR=/scratch/$USER/results ./tsf run # per-shell
TSF_ROOT=/scratch/$USER/TissueSpectF ./tsf run # all three at oncePaths on the command line may be relative: ./tsf changes into the repository
first, so --results-dir results_other is enough and no absolute path is
needed.
--config is the one to reach for when a run tree differs in more than its
output directory — a gene universe, a period floor, a permutation count.
Copy config/project.R, or inherit from it and override only what changes:
# config/other_tree.R
base <- local({
.tsf_root <- Sys.getenv("TSF_ROOT", unset = getwd())
source(file.path(.tsf_root, "config", "project.R"), local = TRUE)$value
})
modifyList(base, list(
results_dir = "results_other"
))Commit it. The file is the record of how a result was produced; a flag typed into a terminal six weeks ago is not. Every run logs the config it read and the results tree it resolved, so a stage never writes somewhere unnoticed.
TSF_ROOT is the one to reach for on a cluster where code and output belong on
different filesystems. Every override is echoed in the log, so a run never
leaves you guessing which tree it wrote to.
To empty the results tree:
make clean-dry # list what would go
make clean # asks you to type the directory name first
make clean-force # no prompt; for scripts that mean itresults_dir points at the active run tree, so make clean deletes real
work — a consensus stage is tens of minutes and is not reproducible from what
survives. It reports the entry count and size, then requires the directory name
typed back. An unanswered or closed stdin counts as a refusal, so a CI job
cannot delete a tree by accident.
The guard is structural, not a length heuristic: scripts/clean_results.R
refuses the filesystem root, the home directory and the repository itself, and
verifies afterwards that the removal actually happened rather than assuming it.
| variable | effect | default |
|---|---|---|
TSF_ROOT |
base for all three directories below | the working directory |
TSF_GEO_DIR |
raw GEO downloads | $TSF_ROOT/data |
TSF_INTERIM_DIR |
the common format | $TSF_ROOT/interim |
TSF_RESULTS_DIR |
spectral outputs | $TSF_ROOT/results |
TSF_LIBRARY_DIR |
condition library, for the peak-gene and sonification scripts | $TSF_RESULTS_DIR/library_domains |
TSF_MAXT_B |
permutations for the per-sample maxT test | as config/project.R says |
TSF_CONDITION_B |
permutations for the condition-level test | as config/project.R says |
TSF_APP_MAX_UPLOAD_MB |
upload cap in the app | 512 |
TSF_MAXT_B and TSF_CONDITION_B exist so a smoke run finishes: they lower the
permutation count, which raises the floor on the smallest reportable p-value to
1/(B+1). Use them for a self-check or a Colab demo, never for a result — the
Colab notebook and CI both set them for exactly that reason and say so.
Every dataset lands in <interim_dir>/<GSE>/ with the same schema, so nothing
downstream needs to know which GEO series it came from:
| file | contents |
|---|---|
samples.tsv |
sample_id, dataset_id, condition, fibrosis_stage, cohort, label_rule, keep |
genes.tsv |
gene_id, gene_name, chr, start, grid_index, grid_N |
grid_coverage.tsv |
observed genes vs grid length, per chromosome |
counts.tsv |
raw counts, gene_id + one column per sample |
expression.tsv |
asinh(TPM) (or asinh(CPM) when gene lengths are unavailable — recorded in the manifest) |
label_audit.tsv |
every input sample, resolved or not, with the rule that fired |
manifest.tsv |
code version, config fingerprint, timestamp, per-condition counts |
The axis is the reference grid: every annotated gene of the allowed biotypes
on a chromosome, ordered by start position, indexed 1..N. N is a property of
the annotation, so it is identical across datasets and conditions and the peak
key (chr, N, k) means the same thing everywhere. Set the universe with
gene_universe in config/project.R (default PROTEIN_CODING|NCRNA).
Genes without an expression measurement keep their grid slot and are simply unobserved. They are never zero-filled or imputed: absence of a measurement is not evidence of zero expression, and a zero at a fixed position injects a deterministic pattern whose own spectral structure can manufacture a peak.
Spectra are therefore computed by least squares (generalised Lomb-Scargle with a
floating mean) over the observed positions only. This is still Fourier analysis:
on a complete grid the fit reproduces the DFT coefficients exactly, which
tests/test_spectrum.R checks against fft() and against a brute-force
least-squares fit on a gapped grid. Internally the sums are evaluated with two
FFTs, so the cost stays O(N log N).
What gaps cost: the sinusoids are no longer orthogonal over the observed
positions, so frequency bins are not independent, Parseval no longer holds
exactly, and the Nyquist limit is not uniquely defined. The first is why the
permutation null is mandatory rather than optional. The third is why every peak
carries window_power and window_rank, and why ./tsf window exists.
./tsf windowComputes, per chromosome, the spectrum of the presence indicator alone -- what the pattern of missing genes can produce with no expression signal at all -- and places every stable peak in it. A peak in the top 1% of the window sits exactly where the gaps are most periodic and should be treated as a sampling artefact until shown otherwise. Run this before interpreting any peak near the Nyquist limit.
The permutation null permutes values among the observed positions, holding the positions fixed, so the missingness pattern is identical in the data and in every permutation and cannot by itself produce significance. That is checked directly in the test suite.
class_id = tissue::state::condition
Three levels because two are not enough. state separates healthy tissue from
diseased tissue, condition names the class within it. With two levels, healthy
liver from a non-disease cohort and healthy liver from a biopsy series would
have to share one label or invent unrelated ones; with three, both sit under
liver::healthy and stay distinguishable, and TCGA's adjacent-normal will fit
later without renaming anything.
| raw label | what it is | cohorts |
|---|---|---|
Normal_histology |
a biopsy with normal histology and no NAFLD activity (NAS = 0), taken within a biopsy series | GSE162694 (31), GSE130970 (6) |
Control_disease_cohort |
a subject outside the disease cohort of a NAFLD study | GSE135251 (8, see below) |
Control_external_study |
the control group of a different disease study | GSE142530 (11) |
All three map to one condition, Controles, 56 samples over four cohorts.
The raw labels stay distinct, so ingest, the audit trail and cohort_roles
still record which cohort a control came from; only the class is shared.
An earlier version kept them apart, on the argument that a healthy liver might
not look the same whichever study recruited it. Two things decided against it,
neither of which depends on any accuracy figure. The source publications place
all three in the same group, and states already marked all three healthy —
the split lived in the label. And the split was not validable: two of the three
existed in a single cohort each, so leave-one-cohort-out had no second cohort to
learn them from. On a real run GSE142530 was skipped as a hold-out entirely,
ten samples were dropped from validation, and both classes sat among the
centroids competing for every prediction while winning zero.
What it costs, stated: the class is heterogeneous in clinical context.
Normal_histology is a NAFLD-cohort patient with no fibrosis yet, not a donor
liver; Control_external_study is the control arm of an alcohol study under a
different protocol.
F0 is not folded in. F0 is a histological stage in a NAFLD patient — a
measurement — while these three are the absence of the disease context. Merging
them would assume what the Controles vs F0 contrast exists to test.
Two of GSE135251's ten controls carried incidental fibrosis of stage 1 and 2
(GSM3998224, GSM3998341). They are excluded by exclude_samples in that
dataset's config, which is why the count above is 8 and the class is 56 rather
than 58. exclude_samples aborts if a named accession is not in the series — a
typo would otherwise exclude nothing and leave the run looking corrected — and
warns instead of aborting when none of them match, which is the legitimate case
for synthetic or subset data.
Excluding by accession rather than by a rule on fibrosis stage matters:
sample_filter evaluates one column across all samples, so a filter on stages 1
and 2 would also delete the 47 F1 and 53 F2 of the NAFLD cohort. This departs
from the publication, which groups all ten as controls — the control class here
is histologically clean, not the paper's recruitment group.
NAFLD_fibrosis_F0 through F4, biopsy fibrosis stages within the disease
cohort. Normal_histology is not F0: in GSE162694 the phenotype table lists
normal liver histology and 0 as separate values, and NAS settles it — all 31
normal-histology samples score 0 while the 35 at stage 0 score 1 to 5.
A vocabulary declares three things beyond its levels:
progression— the ordered subset. Transitions and ordinal trends run over it alone:F0 → F1is a step,Control_disease_cohort → Normal_histologyis not.states— healthy or disease, which becomes the middle term ofclass_id.cohort_roles—control,diseaseorwithin_disease_normal. Deriving the role by comparing against the baseline alone markedControl_external_studyas disease, because it is not the baseline: a conclusion drawn from a naming convention rather than from the biology.
Cross-dataset work runs over comparable_conditions(), which reports what each
dataset lacks instead of silently producing an empty intersection.
| cohort | Control_disease_cohort | Control_external_study | Normal_histology | F0 | F1 | F2 | F3 | F4 |
|---|---|---|---|---|---|---|---|---|
| GSE135251 | 10 | — | — | 38 | 47 | 53 | 54 | 14 |
| GSE162694 | — | — | 31 | 35 | 30 | 27 | 8 | 12 |
| GSE130970 | — | — | 6 | 19 | 28 | 9 | 14 | 2 |
| GSE276114 | — | — | — | — | — | — | 13 | 42 |
| GSE142530 | — | 11 | — | — | — | — | — | — |
GSE276114 spans three etiologies and contributes MASLD only, and only F3/F4: its
F0-2 bin spans three classes and resolves to none of them. GSE142530 is an
alcohol study and contributes only its controls. Neither restriction is a
filter applied for convenience — pooling etiologies would put one class label on
a mixture of diseases.
Read the series before writing rules for it:
Rscript scripts/inspect_series_matrix.R data/<GSE>_series_matrix.txt.gz "field" "other"It prints every phenotype field with its counts and cross-tabulates two of them. That last view is what settles whether a class is what its name suggests — it is how GSE162694's 31 "normal liver histology" samples turned out to have NAS = 0 while its 35 stage-0 samples had NAS 1–5, two groups the pipeline had been merging. Write the config from that output, not from the paper's description of it.
Then config/datasets/<GSE>.R returns a list with id, tissue, vocabulary,
counts_file, series_matrix, has_control_cohort and an ordered
condition_rules list. Four rule types:
column_match— exact match on a phenotype columntitle_token— stage token parsed from the sample titlefibrosis_stage— numeric stage from a phenotype columncompound— every sub-condition must hold, for a class defined by a combination of scores rather than one field
The first rule that resolves a sample wins; samples no rule resolves come back
with condition = NA and are reported, never guessed.
sample_filter— keep only samples matching a field, or exclude those that do. A sample whose filter field is missing is dropped, never admitted: in a mixed cohort an absent label is not evidence that it is the one wanted. A missing filter column aborts rather than admitting everything.keep_conditions— the classes this cohort contributes at all.expected_n_samples— ingest stops if the count changes.
GEO publishes count matrices in whatever shape the submitters chose, and those
are properties of a file rather than of the analysis, so they go in
counts_spec: sep, id_column, symbol_column, skip, sample_map_row,
map_transform, exclude_columns. count_id_type is ENTREZID, ENSEMBL or
SYMBOL.
sample_map_row names a second header row carrying the real sample names. It is
extracted as a map, used to rename the columns, and removed before anything
is coerced to numeric — left in place it turns every count column into character
and the matrix silently becomes text. map_transform reconciles labels that
differ from the phenotype table (Control_Lille 389 against Control_389), and
every substitution is recorded next to the raw value in count_column_map.tsv
with whether it matched. A regular expression that rewrites sample names and
leaves no table behind is not a mapping, it is a guess.
./tsf check # are the GEO files where the configs expect them?
./tsf run # the whole pipeline, in order
./tsf status # what exists on diskrun prints a per-stage timing table and stops at the first failure, so a long
run never leaves you guessing which stage produced which output.
Any stage can be run on its own, over a subset:
./tsf spectra # one stage, all datasets
./tsf maxt GSE135251 --cond F3 # one dataset, one condition
./tsf run --from stability # resume after changing a threshold
./tsf run --to maxt --log run.log # stop early, tee output to a file
./tsf run --dry-run # print the plan, do nothingPaths and parameters are flags, so nothing has to be exported:
./tsf run --to spectra --results-dir results_proteincoding
./tsf run --gene-universe '^(protein-coding|ncRNA)$' \
--results-dir results_pc_lnc --interim-dir interim_pc_lnc
./tsf stability --stable-frac 0.7 --criterion consistency
./tsf reference --target tissue --k-max 96Both --key value and --key=value work, names are case-insensitive, and -
and _ are interchangeable — --results-dir, --results_dir and
--RESULTS_DIR are the same flag. Precedence is command line > environment
(TSF_*) > config/project.R, and every override is echoed in the log.
./tsf --help lists the flags. Stages in order: ingest, spectra, maxt,
condition, consensus, clean, stability, peaks, compare.
The spectrum of the mean profile is not a summary of the per-sample spectra. The
transform is linear, so the spectrum of the mean is the vector mean of the
complex coefficients: a component present in every sample at scattered phases
cancels and disappears. ./tsf consensus therefore works from the per-sample
spectra and reports three things the mean cannot separate — how strong (median
normalised power), how common (prevalence), how aligned (phase-locking value) —
plus a permutation null built by drawing samples at random from the whole
dataset, ignoring condition.
./tsf consensus --n-null 999 --n-boot 200A component is confirmed when prevalence holds, phase alignment is unlikely under uniform phases, and it beats the label-permuted null family-wise. Everything else is exploratory. Clearing zero is not evidence: the score is a product of non-negative quantities, so any signal at all clears it.
Two warnings from this stage are the pipeline saying a claim is not reachable, which is different from absent — a permutation p cannot go below 1/(B+1), so a family with more members than draws can allow has a smallest attainable q above any threshold. The logs print that number.
scripts/build_final_condition_spectra.R turns the per-cohort consensus spectra
into one table per class, aggregating by the median across cohorts rather
than by pooling samples: with 42 of 70 F4 samples from one series, a mean would
place the F4 spectrum where that series is.
Rscript scripts/build_final_condition_spectra.R \
--results-dir results --out-dir results/library_all
Rscript scripts/build_final_condition_spectra.R \
--results-dir results --out-dir results/library_domains \
--min-period auto --period-margin 2 --min-period-biological 10The signature is not a top-N list. Each cohort's pointwise permutation
p-values (p_null, each frequency against its own null) combine by Stouffer
and are BH-adjusted across frequencies; membership is q_meta_null <= 0.05. Combining across cohorts is what makes that cut
reachable — with four cohorts it works at 99 draws, with three at 199, with two
it needs many more, and with one it is impossible at any number of draws.
Those classes are reported single_cohort and provisional.
--min-period removes short periods from the analysis and from the testing
family. That is legitimate because the period is a property of the grid alone,
so the filter can be fixed before a spectrum is seen; filtering by enrichment or
prevalence would not be. The effective floor is the larger of a technical one
(2/coverage + margin, the Nyquist limit corrected for the gaps actually
present) and a biological one (the scale the mechanisms of interest could
produce). Report results with and without it: a component that appears only with
the floor is not a finding of the floor.
chrY and the mitochondrion are excluded by default. chrY's expression tracks the sex composition of a group, which differs between conditions and cohorts, so a component there reports who was recruited.
Three figures per class: power_spectrum_ (every frequency, selected ones
marked), signature_peaks_ (selected only, one panel per chromosome, on a period
axis so chromosomes are comparable), and genome_spectrum_ (all chromosomes on
one axis; that axis is a running frequency index, not a genomic coordinate).
Two criteria are computed for every peak; stability_criterion in
config/project.R picks which one drives is_stable, and both are written
either way.
condition (default) -- a permutation test on the condition's summary signal.
Values are permuted among the observed grid positions, the spectrum recomputed,
and the maximum power over frequencies gives the null. The scheme that
decides is maxt$primary_scheme, all by default: the peak has to survive
the block permutations as well as the full one, because the full permutation
alone is a white-noise null and calls autocorrelation "periodicity"
(Rscript scripts/calibrate_null.R shows the rate; see THEORY.md §5.3). Family-wise: it asks
whether a frequency beats the strongest frequency of a permuted spectrum, which
on 500-800 frequencies is close to asking whether it dominates its chromosome.
Expect single digits, and believe what passes.
condition_fdr -- the same permutations, read pointwise: does this frequency beat its own null? BH across frequencies then controls the false discovery rate instead of the probability of any error. This is the question a signature is about, and it selects far more, a fraction of them false by construction.
The pointwise p needs a pooled null, because against its own frequency it
inherits the 1/(B+1) floor and BH over ~10,000 frequencies would need
B = 200,000. Each frequency's null is standardised by its own mean and standard
deviation and the standardised values are pooled across the chromosome. The
assumption is that the standardised null is exchangeable across frequencies;
what standardising does not remove is the sampling window, which is why
./tsf window is not optional.
consistency -- at least stable_frac of the samples individually
significant. This is a reproducibility requirement, not a test with aggregated
power: each sample has to reach significance alone under family-wise control, so
a real but moderate periodicity present in every sample stays invisible however
many samples there are. Kept as a descriptor reported next to the result, not as
the thing that decides.
Where maxt output exists, a Stouffer combination of the per-sample p-values is
added as a second opinion. It is conservative (its inputs are already family-wise
adjusted) and assumes independence between samples, which replicates only
approximately satisfy.
For the family-wise criterion, multiplicity is corrected over chromosomes, not frequencies: maxT already controls the family-wise error rate across frequencies within a chromosome.
A permutation p-value cannot go below 1/(B+1). Any procedure that multiplies it
by the size of a family therefore has a smallest attainable q, and if that
exceeds the threshold, nothing can pass however strong the signal — a result of
"zero components" would then say nothing about the data. This has bitten in five
different places in this pipeline, so every one of them now computes the
attainable floor and prints it:
Pointwise null: with 20 draws over 297 frequencies the smallest reachable
BH q is 14 > 0.05, so q_null cannot confirm anything.
calibrated cut: 999 draws, 3 cohort(s), 5013 frequencies ->
smallest reachable BH q = 0.0004 (usable)
"Not reachable" and "not present" are different findings. Read the floor before reading a zero.
TissueSpectF_colab.ipynb is where to try a change online: it runs the tests
and the self-check with no data at all, then fetches the five cohorts, checks the
labels against the cohort table, and runs the spectral stages restricted with
--chromosomes so they finish. It ends with the baselines, which are worth
running there even when nothing else is.
Two cores make the per-sample maxt stage impractical, so the notebook skips
it; the rank-based statistics carry the analysis without it. Nothing produced
on a partial genome is a result — it is a rehearsal that proves the code runs
and the labels are right. Two cores make the per-sample
maxt stage impractical (roughly 8 hours), so the notebook runs
--from=condition, which is about 1/n the cost and answers the primary
question. The stability stage falls back to the condition test on its own when
no per-sample maxT exists.
make test # every suite: labels, config, FFT, maxT, CLEAN, stability, Wilson
make test-r # the R suites alone (base R, no install step)
make test-ml # the learned layer's tests (skipped if Python is absent)
./tsf selfcheck # the full pipeline on synthetic data with a known answerselfcheck builds a GEO-shaped dataset in a temporary directory with one
sinusoid injected on chromosome 1, whose amplitude grows with fibrosis stage,
runs every stage, and asserts that the labels resolve as declared, that the
recovered peak is the injected (chr, N, k), that the phase matches, that
amplitude increases across every transition, and that the increase replicates
across both datasets. It exercises the same code a real run does, so a
regression anywhere in the chain shows up. It says nothing about whether a
signal found in real data is biologically meaningful -- only that the machinery
recovers a signal known to be there.
Stages communicate only through files, never through session state, so any
stage can be re-run on its own. After changing stable_frac or alpha in
config/project.R:
./tsf run --from=stabilitymaxT is not recomputed. It is the one stage that reuses existing output by
default, because it is the only one that costs hours; pass --force to
recompute it. Every other stage always recomputes, since silent reuse is how two
different runs end up interleaved in one output tree.
cut -f3 $TSF_INTERIM_DIR/GSE135251/samples.tsv | sort | uniq -c
cut -f3 $TSF_INTERIM_DIR/GSE162694/samples.tsv | sort | uniq -cNo cohort should show a class it does not contribute: GSE162694 has no
Control_*, GSE135251 has no Normal_histology, and Control_disease_cohort
and F0 are separate with different counts. Compare against the cohort table
above; a count that differs means a label went somewhere unexpected, and nothing
downstream is worth reading until it is reconciled.
Early phase. This is the one part of the repository that is not finished, so what exists is stated plainly rather than described as though it were done:
| status | |
|---|---|
ml/dataset.py, ml/splits.py |
done — export to masked tensors, leave-one-cohort-out, leak checks |
ml/prototypes.py, ml/evaluate.py, ml/baselines.py |
done — cohort-balanced prototypes, metrics, the baselines |
tests/ml/ |
done — 46 tests, run in CI |
| the model itself | not written |
config/autoencoder.yaml |
read by nothing yet — it records the intended parameters, not a configuration in use |
So the order is deliberate: the data layer and the bar the model has to clear come first, and the model comes last. Nothing here is on the path of any result the statistical pipeline produces.
A complementary layer, not a replacement. The statistical results — permutation p-values, consensus spectra, replication across cohorts — remain the evidence.
A peak reconstructed by TissueSpect-AE is not statistical or causal evidence on its own. Components only the model finds are reported as AI candidates.
Neither the data layer nor the harness needs torch; the R pipeline and its
tests do not need Python at all.
pip install -r requirements-ml.txt # optional
./tsf ae-prepare --interim-dir interim --results-dir results
python3 scripts/run_baselines.py --data results/autoencoder/data \
--out results/autoencoder/baselines
python3 -m pytest tests/ml/ -qae-prepare exports the per-sample spectra as plain TSVs plus a manifest, and
refuses when cohorts disagree on annotation, gene universe, grid digest or
expression unit — a model trained across two grids would be learning the grids.
Every frequency is exported, not a peak list: a fingerprint does not need any
component to be individually significant, and filtering first would remove
exactly what makes classes separable.
Missing frequencies are carried as a mask with four levels, distinguishing what is above a chromosome's Nyquist limit (structurally absent, identical for every sample) from what this sample could not estimate (varies by sample, and so correlates with cohort). Arrays are rectangular, so unobserved positions hold zero — but only after the mask exists, and normalisation re-zeroes them, because subtracting a centre would otherwise turn every hole into a constant the network could read as a missingness flag.
run_baselines.py runs a cohort-balanced nearest centroid, elastic net and
random forest through leave-one-cohort-out, and prints a per-class report saying
which classes were actually evaluated and which exist in one cohort only and so
contribute nothing to any average. A single macro-F1 presents every class as if
it had been tested equally; with these cohorts it never is.
The column that matters is lift over the training fold's majority class, not
accuracy. This is the bar the autoencoder has to clear, fixed before the model
exists so it cannot be set to fit the answer. If nothing lifts over the baseline,
the spectra do not distinguish stages across cohorts and no architecture fixes
that — a network with a domain adversary trained on a few hundred samples would
find something, and that something would be the cohorts.
pip install -r requirements-sonify.txt
make sonifyOne MIDI composition per class. Core invariant peaks — the components present in
every condition — become a shared accompaniment; each condition's consensus
peaks become a melody over it. So what you hear changing between F0 and F4
is the part of the spectrum that changes, against a bed that does not.
The mapping:
| spectral quantity | musical quantity |
|---|---|
frequency k/N |
pitch, logarithmically — higher spectral frequency, higher pitch |
| phase | a sub-beat timing displacement |
peak strength (invariant_score, meta_score, final_power) |
velocity |
| period | note duration, inversely — long genomic periods, longer notes |
evidence class (robust / candidate) |
articulation; candidates are softer |
Pitches are snapped to D Dorian. That is the one frankly aesthetic decision in the chain: it makes the result listenable, and it does discard information. Frequency still sets pitch height; snapping only quantises it.
Every mapping table is written next to the MIDI, so the transformation stays
inspectable — a note can be traced back to the (chr, N, k) it came from. The
run also writes a manifest and a README_sonification.txt.
python3 scripts/sonify_tissuespectf.py \
--library-dir results/library_domains \
--conditions Normal_histology,F0,F1,F2,F3,F4 \
--bpm 82 --bars 16--library-dir defaults to $TSF_LIBRARY_DIR, then
$TSF_RESULTS_DIR/library_domains, then <repo>/results/library_domains. It
needs the condition library, so run build_final_condition_spectra.R first.
This is not evidence. A sonification is a presentation of a result, not a test of one. Nothing audible here supports a claim that the statistical pipeline does not already support on its own, and a component that sounds striking is not thereby more real. The evidence is the permutation p-values, the consensus spectra and the cross-cohort replication.
Locally, from the repository:
Rscript -e 'install.packages("shiny")' # once
./tsf run --to spectra
./tsf reference
./tsf appThe usual route is a Release: tag a version and GitHub Actions builds the bundle, so the recipient downloads a zip and nobody has to run anything.
git tag v0.2.0 && git push --tags.github/workflows/release.yml fetches the GEO inputs, ingests, builds the
reference, runs the out-of-cohort validation, packages the bundle, and attaches
it to the Release with its SHA-256 and the provenance file as the release notes.
It refuses to publish a reference that does not beat its majority-class
baseline: shipping one would mean distributing confident-looking output with no
information in it. The validation numbers, including the per-coverage-band
table, land on the run's summary page.
The reference needs only ingest — fingerprints come from the expression
matrices — so the slow spectral stages are not on the release path. What does
cost time there is the coverage calibration, which recomputes a fingerprint per
(sample, coverage level, loss mode, mask); workflow_dispatch exposes
n_masks, which is passed through to ./tsf reference --n-masks, for a cheaper
trial build.
Binary references do not belong in git, which is why the bundle is a release asset rather than a committed file.
Locally, the same thing:
./tsf bundle --out TissueSpectF-appProduces a folder (and a zip) that depends on nothing else — not this
repository, not config/project.R, not the interim or results directories, not
the GEO downloads. It contains the reference, the four R modules a query needs,
a launcher for macOS/Linux and one for Windows, a README and a provenance file
recording exactly what the reference was built from and how well it validated.
The recipient unzips it and runs run.sh (or double-clicks run.bat). R is the
only prerequisite; shiny installs itself on first run. Nothing is uploaded.
Opens in a browser and runs entirely on the machine that started it: no upload leaves the computer and no server is contacted. Drop in a counts TSV (gene id column, then one column per sample, Ensembl or Entrez ids) and it returns the ranked classes.
shiny is needed only for app.R. The CLI, including ./tsf match, works on
base R alone.
--features selects the representation. All of them go through the same
leave-one-cohort-out validation, the same training-only feature selection and
the same thresholds, so comparing them isolates the representation rather than
confounding it with the harness.
--features |
features | in one line |
|---|---|---|
amplitude |
~1,500 | log-amplitude per (chromosome, k), the default |
amplitude_phase |
~3,000 | the above, plus amplitude-weighted phase |
period_bins |
~960 | per (chromosome, period) on a common log grid |
period_bins_genomic |
40 | one curve over period, averaged across chromosomes |
band_ratios |
780 | ratios between period bands; invariant to global scale |
expression_baseline |
~14,000 | the control: raw expression, no transform |
(chromosome, k) mixes incomparable scales. k is cycles per chromosome, so
with N = 2066 on chr1 the index k = 64 is a period of 32 genes and with
N = 759 on chr21 it is 12: chr1_k64 and chr21_k64 are columns the model
treats as parallel while they describe different things. Indexing by period
fixes that, and only then can chromosomes be averaged into one curve.
k_max does not apply to the period representations — it caps cycles per
chromosome, which would empty every short-period bin on the long chromosomes.
--grid-axis bp --bin-size 100000 --bin-aggregate mean --bin-min-coverage 0.5
N is the chromosome length in bins, from GRCH38_CHROM_LENGTHS, not the
last annotated gene: ending the axis at the last gene shifts frequencies,
periods and phase, and makes two gene universes give different N for the same
chromosome. A chromosome with no declared length falls back to the last gene
with a warning.
--bin-min-coverage is the fraction of a bin's annotated genes a sample
must measure for that bin to count as measured in that sample. Without it
the same bin means different things across cohorts: the position is shared but
the value is not, because one cohort may measure three of its genes and another
one, and that difference tracks platform and depth rather than biology. A bin
below the threshold is NA for that sample only, and gls_observed() drops it
from that signal's fit. bin_coverage.tsv records every (bin, sample) cell with
its measured and annotated counts — without it there is no way to know later
why a bin was excluded in one sample.
It is a pre-specification: fixed before any result is seen.
A query is aggregated the same way before scoring, using the parameters stored in the reference rather than the current config — a reference built with one set and a query aggregated with another would not be comparable.
The periodogram's variance does not fall as data accumulate: measured cv on pure noise at 26% coverage is 0.943. Multitaper drops it to 0.448, a 2.11x reduction near the ceiling of sqrt(5). Costs 9x in time (maxT goes from 50 minutes to about 7.5 hours) and merges components closer than 2*NW/N.
The default is still periodogram: of the nine validations that would justify
switching, three exist.
Run the sweep before trusting any single number:
for f in amplitude period_bins period_bins_genomic band_ratios expression_baseline; do
./tsf reference GSE130970 GSE135251 GSE142530 GSE162694 GSE276114 \
--config config/project.R --geo-dir data --interim-dir interim \
--results-dir results_feat_$f --cores 24 --seed 42 --features $f
doneexpression_baseline is the one that makes the others interpretable. If raw
expression classifies these cohorts better than the spectrum, the transform is
discarding information and the write-up has to say so; if it classifies worse,
the spectrum is a real compression. Keep --n-features identical across the
sweep — changing it for one invalidates the comparison.
A matcher always returns a best match. Whether it means anything is a separate question, and the interface answers it before showing the result:
-
the banner reports out-of-cohort accuracy against the always-guess-the- commonest-class baseline, and says plainly when the reference does not beat guessing
-
a reference built from one cohort is labelled uncalibrated — internal cross-validation is not evidence here, because a classifier with thousands of features reaches a high internal accuracy by learning batch and sequencing depth, neither of which transfers between studies
-
a top-two margin under 0.02 is reported as not separable
-
if a randomly shuffled copy of the query scores as well, it says there is no usable spectral shape in the file
-
a query below the calibrated rejection threshold is reported
UNKNOWNrather than assigned to the least distant centroid -
thresholds are calibrated per coverage band (90–100%, 75–90%, 50–75%, below 50%), because the similarity distribution shifts as coverage falls; a band with no calibrated threshold gives
UNCALIBRATED_COVERAGE, and below 50% nothing is classified
./tsf reference writes out_of_cohort_predictions.tsv and
confusion_matrix.tsv so the validation can be inspected rather than trusted.
The reference is self-contained: it carries the canonical annotation grid
(not the genes one cohort happened to observe), gene identifiers, species,
genome build, annotation release, frequency ceiling and feature representation.
A reference.rds is portable to a machine that has never seen the cohorts it
was built from. Datasets are refused entry into one reference unless species,
build, release, gene universe and grid digest all match — a feature named
chr7_k12 means a different thing under a different build.
A query is fingerprinted on the genes it actually contains. Genes the file lacks are absent from the observed positions, never zero-filled — the same rule that governs unmeasured genes everywhere else here. The fingerprint is normalised after intersecting with the reference feature space, so query and centroids are standardised over the same features, and similarity is computed over the shared features only: an unobserved frequency contributes nothing rather than contributing the mean.
The reference records the expression unit it was built on, and a TPM query against a CPM reference is refused: length normalisation changes the relative height of every gene, so it changes the spectrum.
Declare what the values are with --input-unit (counts, cpm, tpm,
logged). Duplicate identifiers are summed for counts and refused for anything
already normalised; negative values are refused. A query covering less than 20%
of the grid, or less than 50% of the features the model uses, is reported
LOW_COVERAGE and not scored — in the CLI and in the app alike.
Coverage is calibrated rather than assumed, and measured against the canonical grid rather than against whatever the validation cohort happened to observe: a cohort covering 70% of the grid, masked to 80% of its own genes, is a 56% query, not an 80% one. Validation masks genes five ways — scattered, one retained block, several missing blocks, whole chromosomes, and dropout of the least expressed — recomputes the fingerprint from the survivors, and reports accuracy and threshold per band with the spread between masks. Masking spectral features instead would measure an easier quantity: half a chromosome's genes can go missing and the GLS still estimates nearly every frequency, so feature coverage stays near 100% while gene coverage is 50%.
Two thresholds are computed per band, pooled and conservative, and both
rejection rates are reported; fingerprint$threshold_policy declares which is
applied. The default is pooled: with gene-level masking the conservative
threshold rejected 46–79% of true members.
The rejection threshold is calibrated on the similarity that correct held-out matches reach, per class and per coverage band. It bounds how often a true member is wrongly rejected. It does not bound how often an out-of-domain sample is wrongly accepted: no out-of-domain sample was in the validation. Open-set specificity needs negatives — for a tissue reference, other tissues.
PIPELINE.md has every command for the three spectral axes — gene rank, 100 kb
bins and 250 kb bins — with the stopping points, what to check at each, and the
parameters that are pre-specifications rather than settings.
CC BY-NC 4.0. See LICENSE. All of it — the pipeline, the app, the learned layer and the sonification. One licence, no exceptions.
Use, modify and share it for any non-commercial purpose with attribution; academic research, teaching, peer review and reproducing a published result all qualify and need no permission. Commercial use needs written permission.
Two consequences worth knowing rather than discovering: CC is not a software licence and carries no patent or warranty provisions, and NC is not OSI-approved, so a repository that has to be deposited under an open licence cannot be deposited as-is.