diff --git a/DevDoc/theory/evolutionary_distances/index.html b/DevDoc/theory/evolutionary_distances/index.html index 71d10aa8..e7627d12 100644 --- a/DevDoc/theory/evolutionary_distances/index.html +++ b/DevDoc/theory/evolutionary_distances/index.html @@ -898,6 +898,17 @@ + + +
cardcomp::tests::underflow_prone_transition_gets_finite_cost_no
where the old exp()-first code would have underflowed to 0.0, cost
asserted finite). Every pre-existing cardcomp test still passes
unchanged (numerically identical results when no underflow occurs).
+--iqtree-min-freq: rare states still perturb IQ-TREE after the underflow fix (implemented 2026-08-15)
+After the pairwise_cost_matrix fix above, the user re-checked
+_iqtree_states.csv on the real phyloskims_sal_vac run: no more exact
+zeros, but N (all 4 bases), V/H/D (3 bases each) sit at
+8×10⁻⁵–4×10⁻⁴, and B (3 bases) just above 10⁻³ — all four
+3-simultaneous-base states plus N, exactly the states the underflow fix
+was papering over the symptom of. iqtree3 kept emitting "Numerical
+underflow for lh-derivative" on this same run — a model with several
+near-zero-frequency states is still numerically marginal for ML
+optimisation even once no cost/exchangeability entry is exactly
+infinite/zero.
+Decided and implemented: extend --free-loss's existing "sampling
+failure, not true signal" treatment of non-detection to rare
+multi-base states too — biologically, a family showing 3–4 simultaneously
+observed central bases across genomes is far more likely to be
+assembly/detection noise than a genuinely ancestral, widely-preserved
+polymorphism, the same reasoning already used for -.
+Mechanism (obikmer/src/cmd/phylo/iqtree.rs, write_iqtree): a
+second filtering pass, active only under --free-loss. After the first
+compact_alphabet call (post ascertainment-filtering), any state with
+freq < --iqtree-min-freq (default 0.001) has every occurrence recoded
+to - (recode_symbols_as_absent, a new small helper) — reusing the
+existing -→? machinery in compact_alphabet/write_iqtree_alignment
+rather than duplicating it, since a state recoded to - flows through
+exactly the same downstream path a genuinely-absent call already does.
+drop_ascertainment_noninformative then re-runs on the recoded alignment
+(a site can become non-informative once its only distinguishing call was
+the now-recoded rare state), and compact_alphabet is recomputed a third
+time on the result — this final alphabet is what _iqtree_states.csv/
+_iqtree.model/_iqtree.fasta all get built from, so the three files
+stay consistent with each other by construction, same as before this
+change.
+Not a fixed-point loop (recoding one rare state can't, by construction,
+lower another already-above-threshold state's frequency enough to also
+cross the threshold, since removing calls only redistributes mass upward
+among the survivors) — a single extra pass is sufficient.
+--iqtree-min-freq 0 disables the filter (no state can have frequency
+< 0), recovering the pre-this-change behavior exactly.
+Covered by iqtree::tests::iqtree_min_freq_folds_rare_states_into_missing
+(20 common A/C sites + one site introducing a single, otherwise-informative
+M call at ~1/62 frequency, --iqtree-min-freq 0.05; asserts M absent
+from the written _iqtree_states.csv and A/C still present). Full
+workspace cargo test green.
References
The Mash mutation-rate model this discussion contrasts with:
(Fan et al. 2015; Marbl Lab 2026)1 2.
diff --git a/DevDocMD/theory/evolutionary_distances.md b/DevDocMD/theory/evolutionary_distances.md
index d2d227bf..d1cf4927 100644
--- a/DevDocMD/theory/evolutionary_distances.md
+++ b/DevDocMD/theory/evolutionary_distances.md
@@ -2132,6 +2132,56 @@ where the old `exp()`-first code would have underflowed to `0.0`, cost
asserted finite). Every pre-existing `cardcomp` test still passes
unchanged (numerically identical results when no underflow occurs).
+## `--iqtree-min-freq`: rare states still perturb IQ-TREE after the underflow fix (implemented 2026-08-15)
+
+After the `pairwise_cost_matrix` fix above, the user re-checked
+`_iqtree_states.csv` on the real `phyloskims_sal_vac` run: no more exact
+zeros, but `N` (all 4 bases), `V`/`H`/`D` (3 bases each) sit at
+`8×10⁻⁵`–`4×10⁻⁴`, and `B` (3 bases) just above `10⁻³` — all four
+3-simultaneous-base states plus `N`, exactly the states the underflow fix
+was papering over the symptom of. `iqtree3` kept emitting "Numerical
+underflow for lh-derivative" on this same run — a model with several
+near-zero-frequency states is still numerically marginal for ML
+optimisation even once no cost/exchangeability entry is *exactly*
+infinite/zero.
+
+**Decided and implemented**: extend `--free-loss`'s existing "sampling
+failure, not true signal" treatment of non-detection to rare
+multi-base states too — biologically, a family showing 3–4 simultaneously
+observed central bases across genomes is far more likely to be
+assembly/detection noise than a genuinely ancestral, widely-preserved
+polymorphism, the same reasoning already used for `-`.
+
+**Mechanism** (`obikmer/src/cmd/phylo/iqtree.rs`, `write_iqtree`): a
+*second* filtering pass, active only under `--free-loss`. After the first
+`compact_alphabet` call (post ascertainment-filtering), any state with
+`freq < --iqtree-min-freq` (default `0.001`) has every occurrence recoded
+to `-` (`recode_symbols_as_absent`, a new small helper) — reusing the
+*existing* `-`→`?` machinery in `compact_alphabet`/`write_iqtree_alignment`
+rather than duplicating it, since a state recoded to `-` flows through
+exactly the same downstream path a genuinely-absent call already does.
+`drop_ascertainment_noninformative` then re-runs on the recoded alignment
+(a site can become non-informative once its only distinguishing call was
+the now-recoded rare state), and `compact_alphabet` is recomputed a third
+time on the result — this final alphabet is what `_iqtree_states.csv`/
+`_iqtree.model`/`_iqtree.fasta` all get built from, so the three files
+stay consistent with each other by construction, same as before this
+change.
+
+Not a fixed-point loop (recoding one rare state can't, by construction,
+lower another *already-above-threshold* state's frequency enough to also
+cross the threshold, since removing calls only redistributes mass upward
+among the survivors) — a single extra pass is sufficient.
+
+`--iqtree-min-freq 0` disables the filter (no state can have frequency
+`< 0`), recovering the pre-this-change behavior exactly.
+
+Covered by `iqtree::tests::iqtree_min_freq_folds_rare_states_into_missing`
+(20 common A/C sites + one site introducing a single, otherwise-informative
+`M` call at ~1/62 frequency, `--iqtree-min-freq 0.05`; asserts `M` absent
+from the written `_iqtree_states.csv` and `A`/`C` still present). Full
+workspace `cargo test` green.
+
## References
The Mash mutation-rate model this discussion contrasts with:
diff --git a/UserDocMD/usage/phylo.md b/UserDocMD/usage/phylo.md
index 8d0fe5a5..7da90cc9 100644
--- a/UserDocMD/usage/phylo.md
+++ b/UserDocMD/usage/phylo.md
@@ -124,6 +124,7 @@ The first `phylo` run on a given index that uses `--entropy`/`--entropy-sd` pays
| `--tnt` | off | Also write a TNT script (implies `--sankoff`) |
| `--phyg` | off | Also write PhyG input files (implies `--sankoff`) |
| `--iqtree` | off | Also write an IQ-TREE custom model and alignment (implies `--sankoff`) |
+| `--iqtree-min-freq` | `0.001` | With `--iqtree --free-loss`: also treat as missing any state rarer than this in the alignment |
| `--sankoff-cost-scale` | `100` | Integer scaling factor applied to costs before rounding (required by TNT/PhyG's integer-only cost commands) |
### The 16-state model
@@ -232,9 +233,13 @@ Matrix layout (`_dist.csv`, `_shared.csv`, and every other "CSV matrix" below):
**`_iqtree.model`** (`--iqtree`) — lower-triangular exchangeability matrix $R(a,b) = e^{-\text{cost}(a,b)}$ (one row of increasing length per state, whitespace-separated, PAML order), followed by one line of empirical state frequencies. Only states actually occurring in the alignment are kept, compactly renumbered `0..k-1`.
-**`_iqtree.fasta`** (`--iqtree`) — alignment recoded to that same compact `0..k-1` alphabet (symbols `0-9A-F`). Under `--free-loss`, non-detection becomes `?` and columns left non-informative once missing calls are ignored are dropped first (required for `+ASC`). Run with:
+**`_iqtree.fasta`** (`--iqtree`) — alignment recoded to that same compact `0..k-1` alphabet (symbols `0-9A-F`). Under `--free-loss`, non-detection becomes `?` and columns left non-informative once missing calls are ignored are dropped first (required for `+ASC`); with `--iqtree-min-freq` also set (the default), any state rarer than that threshold is folded into the same `?` treatment, and non-informative columns are re-checked and dropped again after that. Run with:
```
iqtree3 -s _iqtree.fasta --seqtype MORPH -m _iqtree.model+ASC --prefix _iqtree -T AUTO
```
-**`_iqtree_states.csv`** (`--iqtree`) — one row per state actually kept in `_iqtree.model`/`_iqtree.fasta` (header `iqtree_symbol,canonical_symbol,frequency`): `iqtree_symbol` is the compact `0-9A-F` symbol as written in those two files, `canonical_symbol` is the matching `_sankoff_matrix.csv` state, `frequency` is that state's empirical frequency at full precision (`_iqtree.model`'s own frequency line is rounded to 6 decimals). Under `--free-loss`, absent (`0`/`?`) is never a kept state, so it never appears here. Use this file to identify which real state a given row/column of `_iqtree.model`'s matrix corresponds to — e.g. to check whether a state showing zero exchangeability with everything else is expected (a state combination that never co-occurs with anything else in this data) or worth investigating further.
+**`_iqtree_states.csv`** (`--iqtree`) — one row per state actually kept in `_iqtree.model`/`_iqtree.fasta` (header `iqtree_symbol,canonical_symbol,frequency`): `iqtree_symbol` is the compact `0-9A-F` symbol as written in those two files, `canonical_symbol` is the matching `_sankoff_matrix.csv` state, `frequency` is that state's empirical frequency at full precision (`_iqtree.model`'s own frequency line is rounded to 6 decimals). Under `--free-loss`, absent (`0`/`?`) is never a kept state, so it never appears here — nor does any state `--iqtree-min-freq` folded away for being too rare. Use this file to identify which real state a given row/column of `_iqtree.model`'s matrix corresponds to — e.g. to check whether a state showing zero exchangeability with everything else is expected (a state combination that never co-occurs with anything else in this data) or worth investigating further.
+
+### Rare states and `--iqtree-min-freq`
+
+States that combine 3 or 4 central bases at once (IUPAC `V`/`H`/`K`.../`N`) are inherently rare — and, on real data, rare enough that they can make `iqtree3` itself numerically unstable ("Numerical underflow for lh-derivative" warnings, near-degenerate likelihood optimization). They're also more likely to be assembly/detection noise than genuine, widely-shared multi-way polymorphism, the same "sampling failure, not true signal" reasoning `--free-loss` already applies to non-detection. With `--free-loss` set, `--iqtree-min-freq` (default `0.001`, i.e. one in a thousand) extends that same missing-data treatment to any state below this frequency, not just absence. Check `_iqtree_states.csv` to see exactly which states survived and at what frequency; set `--iqtree-min-freq 0` to disable this and keep every state that occurs at all (the old behavior). Has no effect without `--free-loss` — there is no missing-data symbol to fold rare states into otherwise.
diff --git a/aloss_nofl_sankoff_params.yaml b/aloss_nofl_sankoff_params.yaml
new file mode 100644
index 00000000..1e488fab
--- /dev/null
+++ b/aloss_nofl_sankoff_params.yaml
@@ -0,0 +1,167 @@
+ratio_ceiling: 0.5
+cardinality_transitions:
+- from: 0
+ to: 0
+ count: 122014779
+ probability: 0.870049812090934
+- from: 0
+ to: 1
+ count: 17966272
+ probability: 0.12811195254940888
+- from: 0
+ to: 2
+ count: 233683
+ probability: 0.001666321505518981
+- from: 0
+ to: 3
+ count: 24109
+ probability: 0.00017191385413811494
+- from: 0
+ to: 4
+ count: 0
+ probability: 0.0
+- from: 1
+ to: 0
+ count: 17966272
+ probability: 0.967023782579572
+- from: 1
+ to: 1
+ count: 602798
+ probability: 0.03244523972983381
+- from: 1
+ to: 2
+ count: 9562
+ probability: 0.0005146688978673966
+- from: 1
+ to: 3
+ count: 303
+ probability: 0.00001630879272681669
+- from: 1
+ to: 4
+ count: 0
+ probability: 0.0
+- from: 2
+ to: 0
+ count: 233683
+ probability: 0.9585500516842502
+- from: 2
+ to: 1
+ count: 9562
+ probability: 0.039222603245442765
+- from: 2
+ to: 2
+ count: 438
+ probability: 0.0017966429848885097
+- from: 2
+ to: 3
+ count: 105
+ probability: 0.00043070208541847837
+- from: 2
+ to: 4
+ count: 0
+ probability: 0.0
+- from: 3
+ to: 0
+ count: 24109
+ probability: 0.9827171564831044
+- from: 3
+ to: 1
+ count: 303
+ probability: 0.012350711286838137
+- from: 3
+ to: 2
+ count: 105
+ probability: 0.004279949455834998
+- from: 3
+ to: 3
+ count: 16
+ probability: 0.0006521827742224758
+- from: 3
+ to: 4
+ count: 0
+ probability: 0.0
+- from: 4
+ to: 0
+ count: 0
+ probability: 0.0
+- from: 4
+ to: 1
+ count: 0
+ probability: 0.0
+- from: 4
+ to: 2
+ count: 0
+ probability: 0.0
+- from: 4
+ to: 3
+ count: 0
+ probability: 0.0
+- from: 4
+ to: 4
+ count: 0
+ probability: 0.0
+composition_transitions:
+- from: 'A'
+ to: 'A'
+ count: 0
+ probability: 0.0
+- from: 'A'
+ to: 'C'
+ count: 0
+ probability: 0.0
+- from: 'A'
+ to: 'G'
+ count: 0
+ probability: 0.0
+- from: 'A'
+ to: 'T'
+ count: 0
+ probability: 0.0
+- from: 'C'
+ to: 'A'
+ count: 0
+ probability: 0.0
+- from: 'C'
+ to: 'C'
+ count: 96389
+ probability: 0.9663832688335907
+- from: 'C'
+ to: 'G'
+ count: 1290
+ probability: 0.012933368089671353
+- from: 'C'
+ to: 'T'
+ count: 2063
+ probability: 0.020683363076737984
+- from: 'G'
+ to: 'A'
+ count: 0
+ probability: 0.0
+- from: 'G'
+ to: 'C'
+ count: 1290
+ probability: 0.005696948820201646
+- from: 'G'
+ to: 'G'
+ count: 222720
+ probability: 0.9835848381669073
+- from: 'G'
+ to: 'T'
+ count: 2427
+ probability: 0.010718213012891003
+- from: 'T'
+ to: 'A'
+ count: 0
+ probability: 0.0
+- from: 'T'
+ to: 'C'
+ count: 2063
+ probability: 0.007305266661709142
+- from: 'T'
+ to: 'G'
+ count: 2427
+ probability: 0.008594223067362136
+- from: 'T'
+ to: 'T'
+ count: 277909
+ probability: 0.9841005102709287
diff --git a/aloss_sankoff_params.yaml b/aloss_sankoff_params.yaml
new file mode 100644
index 00000000..e30daac6
--- /dev/null
+++ b/aloss_sankoff_params.yaml
@@ -0,0 +1,167 @@
+ratio_ceiling: 0.5
+cardinality_transitions:
+- from: 0
+ to: 0
+ count: 13019012
+ probability: 0.8682962128540935
+- from: 0
+ to: 1
+ count: 1945560
+ probability: 0.12975810913150784
+- from: 0
+ to: 2
+ count: 26381
+ probability: 0.0017594670310852958
+- from: 0
+ to: 3
+ count: 2792
+ probability: 0.000186210983313375
+- from: 0
+ to: 4
+ count: 0
+ probability: 0.0
+- from: 1
+ to: 0
+ count: 1945560
+ probability: 0.9666005228573729
+- from: 1
+ to: 1
+ count: 66078
+ probability: 0.03282912341401421
+- from: 1
+ to: 2
+ count: 1123
+ probability: 0.0005579331334776772
+- from: 1
+ to: 3
+ count: 25
+ probability: 0.000012420595135300028
+- from: 1
+ to: 4
+ count: 0
+ probability: 0.0
+- from: 2
+ to: 0
+ count: 26381
+ probability: 0.9567346050627402
+- from: 2
+ to: 1
+ count: 1123
+ probability: 0.04072677159643142
+- from: 2
+ to: 2
+ count: 51
+ probability: 0.0018495684340320592
+- from: 2
+ to: 3
+ count: 19
+ probability: 0.0006890549067962574
+- from: 2
+ to: 4
+ count: 0
+ probability: 0.0
+- from: 3
+ to: 0
+ count: 2792
+ probability: 0.9841381741275996
+- from: 3
+ to: 1
+ count: 25
+ probability: 0.008812125484666901
+- from: 3
+ to: 2
+ count: 19
+ probability: 0.0066972153683468455
+- from: 3
+ to: 3
+ count: 1
+ probability: 0.00035248501938667606
+- from: 3
+ to: 4
+ count: 0
+ probability: 0.0
+- from: 4
+ to: 0
+ count: 0
+ probability: 0.0
+- from: 4
+ to: 1
+ count: 0
+ probability: 0.0
+- from: 4
+ to: 2
+ count: 0
+ probability: 0.0
+- from: 4
+ to: 3
+ count: 0
+ probability: 0.0
+- from: 4
+ to: 4
+ count: 0
+ probability: 0.0
+composition_transitions:
+- from: 'A'
+ to: 'A'
+ count: 0
+ probability: 0.0
+- from: 'A'
+ to: 'C'
+ count: 0
+ probability: 0.0
+- from: 'A'
+ to: 'G'
+ count: 0
+ probability: 0.0
+- from: 'A'
+ to: 'T'
+ count: 0
+ probability: 0.0
+- from: 'C'
+ to: 'A'
+ count: 0
+ probability: 0.0
+- from: 'C'
+ to: 'C'
+ count: 10771
+ probability: 0.9619540948468339
+- from: 'C'
+ to: 'G'
+ count: 163
+ probability: 0.014557470751094042
+- from: 'C'
+ to: 'T'
+ count: 263
+ probability: 0.023488434402071982
+- from: 'G'
+ to: 'A'
+ count: 0
+ probability: 0.0
+- from: 'G'
+ to: 'C'
+ count: 163
+ probability: 0.006662851536952256
+- from: 'G'
+ to: 'G'
+ count: 24073
+ probability: 0.9840173315892741
+- from: 'G'
+ to: 'T'
+ count: 228
+ probability: 0.009319816873773708
+- from: 'T'
+ to: 'A'
+ count: 0
+ probability: 0.0
+- from: 'T'
+ to: 'C'
+ count: 263
+ probability: 0.008464484567603231
+- from: 'T'
+ to: 'G'
+ count: 228
+ probability: 0.007338032248720672
+- from: 'T'
+ to: 'T'
+ count: 30580
+ probability: 0.9841974831836761
diff --git a/doc/usage/phylo/index.html b/doc/usage/phylo/index.html
index e19e2aca..c5488ce8 100644
--- a/doc/usage/phylo/index.html
+++ b/doc/usage/phylo/index.html
@@ -946,6 +946,17 @@
+ --sankoff)--iqtree-min-freq0.001--iqtree --free-loss: also treat as missing any state rarer than this in the alignment--sankoff-cost-scale100_sankoff.tcm (--phyg) — first line: the 16-symbol alphabet plus a trailing gap symbol (17 total). Each following line: one row of the integer-scaled, metric-closed cost matrix (17 values — the extra gap column/row reuses the cost to/from the empty state 0, since it's never actually triggered).
_sankoff.pg (--phyg) — script: read(prefasta:..., tcm:...) against _sankoff.fasta/_sankoff.tcm, a default 300s/4-instance search, report(...) writing <prefix>_sankoff.tre (bare numeric labels, as for --tnt). Run with phyg from the output directory (the script uses relative file names). Feed the tree plus _sankoff.fasta into name-tree for taxon names.
_iqtree.model (--iqtree) — lower-triangular exchangeability matrix \(R(a,b) = e^{-\text{cost}(a,b)}\) (one row of increasing length per state, whitespace-separated, PAML order), followed by one line of empirical state frequencies. Only states actually occurring in the alignment are kept, compactly renumbered 0..k-1.
_iqtree.fasta (--iqtree) — alignment recoded to that same compact 0..k-1 alphabet (symbols 0-9A-F). Under --free-loss, non-detection becomes ? and columns left non-informative once missing calls are ignored are dropped first (required for +ASC). Run with:
+
_iqtree.fasta (--iqtree) — alignment recoded to that same compact 0..k-1 alphabet (symbols 0-9A-F). Under --free-loss, non-detection becomes ? and columns left non-informative once missing calls are ignored are dropped first (required for +ASC); with --iqtree-min-freq also set (the default), any state rarer than that threshold is folded into the same ? treatment, and non-informative columns are re-checked and dropped again after that. Run with:
iqtree3 -s <prefix>_iqtree.fasta --seqtype MORPH -m <prefix>_iqtree.model+ASC --prefix <prefix>_iqtree -T AUTO
_iqtree_states.csv (--iqtree) — one row per state actually kept in _iqtree.model/_iqtree.fasta (header iqtree_symbol,canonical_symbol,frequency): iqtree_symbol is the compact 0-9A-F symbol as written in those two files, canonical_symbol is the matching _sankoff_matrix.csv state, frequency is that state's empirical frequency at full precision (_iqtree.model's own frequency line is rounded to 6 decimals). Under --free-loss, absent (0/?) is never a kept state, so it never appears here. Use this file to identify which real state a given row/column of _iqtree.model's matrix corresponds to — e.g. to check whether a state showing zero exchangeability with everything else is expected (a state combination that never co-occurs with anything else in this data) or worth investigating further.
_iqtree_states.csv (--iqtree) — one row per state actually kept in _iqtree.model/_iqtree.fasta (header iqtree_symbol,canonical_symbol,frequency): iqtree_symbol is the compact 0-9A-F symbol as written in those two files, canonical_symbol is the matching _sankoff_matrix.csv state, frequency is that state's empirical frequency at full precision (_iqtree.model's own frequency line is rounded to 6 decimals). Under --free-loss, absent (0/?) is never a kept state, so it never appears here — nor does any state --iqtree-min-freq folded away for being too rare. Use this file to identify which real state a given row/column of _iqtree.model's matrix corresponds to — e.g. to check whether a state showing zero exchangeability with everything else is expected (a state combination that never co-occurs with anything else in this data) or worth investigating further.
--iqtree-min-freqStates that combine 3 or 4 central bases at once (IUPAC V/H/K.../N) are inherently rare — and, on real data, rare enough that they can make iqtree3 itself numerically unstable ("Numerical underflow for lh-derivative" warnings, near-degenerate likelihood optimization). They're also more likely to be assembly/detection noise than genuine, widely-shared multi-way polymorphism, the same "sampling failure, not true signal" reasoning --free-loss already applies to non-detection. With --free-loss set, --iqtree-min-freq (default 0.001, i.e. one in a thousand) extends that same missing-data treatment to any state below this frequency, not just absence. Check _iqtree_states.csv to see exactly which states survived and at what frequency; set --iqtree-min-freq 0 to disable this and keep every state that occurs at all (the old behavior). Has no effect without --free-loss — there is no missing-data symbol to fold rare states into otherwise.