Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.0] - 2026-07-01

### Added

- `allotaxonometer` — the full Dodds (2020) diamond allotaxonograph: a
rotated-square rank-rank histogram (log color scale) with iso-divergence
contours, log-rank edge axes, exclusive edge strips, and a wordshift + balance
panel. Complements the existing two-panel `allotaxonograph`, which is unchanged.
- `RankedList.from_scores` — rank types by any numeric score (keyness score, log
ratio, …), so keyword rankings, "frequent words vs keywords", and other
non-frequency comparisons are first-class inputs to `rtd`/`allotaxonometer`.
- Presidential-speech gallery: diamond figures and a frequency-vs-keyness diamond;
README research note on comparing many rankings.

## [0.1.2] - 2026-07-01

### Fixed
Expand Down
36 changes: 31 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,12 @@ fig.savefig("allotaxonograph.png")
- **Keyness measures**: log-likelihood (Dunning), log ratio, Simple Maths, %DIFF, and
chi-square (for contrast) — significance flagged against the chi-square thresholds
- **Keywords and lockwords**: positive / negative keywords plus the stable lockword zone
- **Rank-turbulence divergence**: tunable, rank-sensitive corpus comparison with
per-type contributions and an explicit alpha-to-zero log limit
- **Allotaxonograph**: publication-quality two-panel matplotlib figure, no JS runtime
- **Rank-turbulence divergence**: tunable, rank-sensitive comparison of *any* two
rankings (frequency, keyness score, …) with per-type contributions and an
explicit alpha-to-zero log limit
- **Allotaxonographs**: a two-panel view (`allotaxonograph`) and the full Dodds
(2020) **diamond** (`allotaxonometer`) — rank-rank histogram, iso-divergence
contours, wordshift — publication-quality matplotlib, no JS runtime
- **Reproducibility records**: every keyness result emits its reference, cutoffs, and measure

## Documentation
Expand All @@ -65,6 +68,28 @@ Full documentation — quickstart, the keyness and allotaxonograph tutorials,
troubleshooting, and the complete API reference — is at
[keyflux.readthedocs.io](https://keyflux.readthedocs.io). The sources live in `docs/`.

## Research direction: comparing many rankings

Rank-turbulence divergence and the allotaxonograph are **pairwise** — they compare
two rankings at a time. This is true of the whole allotaxonometry line, including
the 2025 tooling suite ([arXiv:2506.21808](https://arxiv.org/abs/2506.21808)). But
the questions we care about are often *many*-way: how does presidential vocabulary
drift across **all eleven** eras at once? Which of a dozen speaker groups is the
outlier? Comparing many rankings simultaneously is an open problem we intend to
research and, eventually, support.

The nearest existing framework is **rank aggregation** — finding a consensus
ranking that best agrees with a set of input rankings. The classic formulation is
the **Kemeny median** (minimise total pairwise disagreement), which is NP-hard,
with squared-distance and set-wise / k-wise generalisations
([Kemeny aggregation](https://arxiv.org/abs/1402.5259);
[squared Kemeny](https://arxiv.org/html/2404.08474v1);
[set-wise Kemeny](https://www.sciencedirect.com/science/article/abs/pii/S030439752100414X)).
Candidate directions for keyflux: a **pairwise RTD matrix** (all-pairs divergence
+ clustering/MDS of systems), **consensus-vs-each** allotaxonographs (compare every
ranking against an aggregate), and **time-series flipbooks** of successive
allotaxonographs. If you work on this, we'd love to hear from you.

## Roadmap

Planned for the next iteration. The robustness items are analysed in detail in
Expand All @@ -79,12 +104,13 @@ Planned for the next iteration. The robustness items are analysed in detail in

**Proposed features**

- [ ] `RankedList.from_keyness(..., by="score")` — rank by keyness score, not just frequency, so "compare the distinctive-word lists over time" is a one-liner.
- [x] Rank by any score, not just frequency (`RankedList.from_scores`) — compare keyword rankings, keyness scores, or any metric.
- [ ] Comparing many rankings at once — see [Research direction](#research-direction-comparing-many-rankings) above.
- [ ] Optional self-contained interactive HTML+JS allotaxonograph export (an alpha slider), gated behind an extra so the core stays pure Python.

**Maintenance**

- [ ] Publish to PyPI and wire up ReadTheDocs.
- [x] Publish to PyPI and wire up ReadTheDocs.

## Made by

Expand Down
6 changes: 6 additions & 0 deletions docs/api/allotaxonometer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Allotaxonometer

The Dodds (2020) diamond allotaxonograph — a rotated-square rank-rank histogram
with iso-divergence contours and a wordshift list. Works on any two ranked lists.

::: keyflux.viz.allotaxonometer.allotaxonometer
20 changes: 20 additions & 0 deletions docs/examples/presidential_speeches.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,26 @@ The 19thC↔21stC pair at three settings (divergence rises from 0.39 to 0.54):

![alpha 1](https://raw.githubusercontent.com/crow-intelligence/keyflux/main/examples/gallery/alpha_sweep_1.png)

## The diamond allotaxonograph

The same comparison in the canonical Dodds (2020) **diamond** (`allotaxonometer`):
a rotated-square rank-rank histogram with iso-divergence contours and a wordshift
list. Shared function words sit on the vertical centre near the top; era-specific
and exclusive words fan out to the lower edges.

![diamond 1825–1849 vs 2000–2024](https://raw.githubusercontent.com/crow-intelligence/keyflux/main/examples/gallery/diamond_2000-2024_vs_1825-1849.png)

## Frequent words vs. keywords — two rankings of one corpus

`allotaxonometer` compares *any* two rankings, not just two corpora. Here the
**2000–2024** corpus is ranked two ways — by raw **frequency** and by **keyness**
(log-likelihood vs the 1825–1849 reference, via `RankedList.from_scores`) — and
diamonded against itself. Function words (*the, be, and, of*) top the frequency
ranking but fall out of the keyness ranking; deictic and content words (*we, you,
america, thank*) rise. Same vocabulary, reordered.

![frequency vs keyness](https://raw.githubusercontent.com/crow-intelligence/keyflux/main/examples/gallery/diamond_frequency_vs_keyness.png)

## The script

```python title="examples/presidential_speeches.py"
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
256 changes: 217 additions & 39 deletions examples/presidential_speeches.ipynb

Large diffs are not rendered by default.

60 changes: 59 additions & 1 deletion examples/presidential_speeches.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@
from collections import Counter
from pathlib import Path

from keyflux import Keyness, RankedList, allotaxonograph, load_counts, rtd
from keyflux import (
Keyness,
RankedList,
allotaxonograph,
allotaxonometer,
load_counts,
rtd,
)

try:
HERE = Path(__file__).resolve().parent
Expand Down Expand Up @@ -141,3 +148,54 @@ def compare(focus_period, reference_period, *, alpha=1 / 3, save_as=None):
fig = compare("2000-2024", "1825-1849", alpha=a,
save_as=f"alpha_sweep_{tag}.png")
show(fig)

# %% [markdown]
# ## The diamond allotaxonograph (`allotaxonometer`)
#
# The same comparisons in the canonical Dodds (2020) diamond: a rotated-square
# rank-rank histogram with iso-divergence contours and a wordshift list. Shared
# function words sit on the vertical centre near the top; era-specific and
# exclusive words fan out to the edges.


# %%
def diamond(focus_period, reference_period, *, alpha=1 / 3, save_as=None):
"""Render the diamond allotaxonograph for two eras; returns the Figure."""
r_focus = RankedList.from_counts(counts[focus_period], label=focus_period)
r_reference = RankedList.from_counts(
counts[reference_period], label=reference_period
)
fig = allotaxonometer(r_focus, r_reference, alpha=alpha)
if save_as:
fig.savefig(GALLERY / save_as, dpi=130, bbox_inches="tight")
print(f"saved gallery/{save_as}")
return fig


# %%
show(diamond("2000-2024", "1825-1849", save_as="diamond_2000-2024_vs_1825-1849.png"))

# %%
show(diamond("2000-2024", "1950-1974", save_as="diamond_2000-2024_vs_1950-1974.png"))

# %% [markdown]
# ## Frequent words vs. keywords — two rankings of one corpus
#
# `allotaxonometer` compares *any* two rankings. Here we rank the 2000–2024
# corpus two ways — by raw frequency and by keyness (log-likelihood vs the
# 1825–1849 reference, via `RankedList.from_scores`) — and diamond them against
# each other. Function words top the frequency ranking but vanish from the
# keyness ranking; content words leap up. It's the same vocabulary, reordered.

# %%
freq_rank = RankedList.from_counts(counts["2000-2024"], label="by frequency")
_k = Keyness(counts["2000-2024"], counts["1825-1849"],
min_focus_freq=10, min_reference_freq=10)
key_scores = {r.type: r.statistic for r in _k.table() if r.direction == "positive"}
key_rank = RankedList.from_scores(key_scores, label="by keyness")
fig_fk = allotaxonometer(freq_rank, key_rank, alpha=1 / 3)
fig_fk.savefig(
GALLERY / "diamond_frequency_vs_keyness.png", dpi=130, bbox_inches="tight"
)
print("saved gallery/diamond_frequency_vs_keyness.png")
show(fig_fk)
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,6 @@ nav:
- api/rankedlist.md
- api/rtd.md
- api/allotaxonograph.md
- api/allotaxonometer.md
- api/corpus.md
- api/datasets.md
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "keyflux"
version = "0.1.2"
version = "0.2.0"
description = "Corpus keyness, rank-turbulence divergence, and allotaxonographs"
readme = "README.md"
authors = [
Expand Down
3 changes: 2 additions & 1 deletion src/keyflux/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
ReproRecord,
)
from keyflux.ranking import RankedList
from keyflux.viz import allotaxonograph
from keyflux.viz import allotaxonograph, allotaxonometer

try:
__version__ = version("keyflux")
Expand All @@ -28,6 +28,7 @@
"RTDResult",
"Contribution",
"allotaxonograph",
"allotaxonometer",
"counts_from_tokens",
"counts_from_text",
"load_counts",
Expand Down
50 changes: 45 additions & 5 deletions src/keyflux/ranking/rankedlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,18 +57,22 @@ def average_ranks(counts: Mapping[str, float]) -> dict[str, float]:

@dataclass(frozen=True, slots=True)
class RankedList:
"""A type-to-rank mapping derived from frequency counts.
"""A type-to-rank mapping derived from counts or scores.

Attributes:
ranks: Type-to-rank mapping within this list's own domain (average ties).
counts: The original counts the ranks were derived from.
total: Sum of the counts.
counts: The values the ranks were derived from — frequency counts
(:meth:`from_counts`) or arbitrary scores (:meth:`from_scores`).
Used by :meth:`aligned` to place a type absent from the other list at
a tied-last rank, so scores should be non-negative "prominence"
values for that comparison semantics to hold.
total: Sum of the values.
label: An optional label (e.g. a time period) for plots and results.
"""

ranks: Mapping[str, float]
counts: Mapping[str, int]
total: int
counts: Mapping[str, float]
total: float
label: str = field(default="")

@classmethod
Expand Down Expand Up @@ -103,6 +107,42 @@ def from_counts(cls, counts: Mapping[str, int], *, label: str = "") -> RankedLis
label=label,
)

@classmethod
def from_scores(cls, scores: Mapping[str, float], *, label: str = "") -> RankedList:
"""Build a ranked list by ranking types on descending score.

Ranks by any numeric metric — keyness score, log ratio, salience — so a
keyword ranking (or any other) can be compared like a frequency ranking.

Args:
scores: Mapping of type to score. The highest score gets rank 1.
Scores are treated as prominence: when two lists are aligned, a
type absent from one gets score 0 there (tied-last), so
non-negative scores keep that behaviour intuitive.
label: Optional label for the list.

Returns:
A ``RankedList`` whose ranks average tied scores.

Raises:
ValueError: If ``scores`` is empty.

Examples:
>>> r = RankedList.from_scores({"climate": 6.4, "carbon": 5.8, "the": 0.1})
>>> r.ranks["climate"], r.ranks["the"]
(1.0, 3.0)
"""
if not scores:
msg = "Cannot build a RankedList from empty scores."
raise ValueError(msg)
plain = dict(scores)
return cls(
ranks=average_ranks(plain),
counts=plain,
total=sum(plain.values()),
label=label,
)

@classmethod
def from_keyness(
cls, keyness: Keyness, *, side: str = "focus", label: str = ""
Expand Down
3 changes: 2 additions & 1 deletion src/keyflux/viz/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Visualization: matplotlib allotaxonographs (no JavaScript runtime)."""

from keyflux.viz.allotaxonograph import allotaxonograph
from keyflux.viz.allotaxonometer import allotaxonometer

__all__ = ["allotaxonograph"]
__all__ = ["allotaxonograph", "allotaxonometer"]
Loading
Loading