Metrical-phonological analysis of poetry (and prose) in Python
Prosodic parses text into a linguistic hierarchy (text → stanza → line → word → syllable → phoneme), runs a constraint-based metrical parser over every line, and identifies stress patterns (iambic, trochaic, anapestic, dactylic), foot/syllable schemes, and named rhyme schemes. Everything on this page is real executed output.
Try it without installing: the hosted web app at prosodic.app
Plus espeak for words not in the CMU dictionary (brew install espeak / apt-get install espeak) — see Install for details and optional extras.
Quickstart
import warnings; warnings.filterwarnings('ignore')import prosodicsonnet = prosodic.Text("""When in the chronicle of wasted timeI see descriptions of the fairest wights,And beauty making beautiful old rhymeIn praise of ladies dead and lovely knights,Then, in the blazon of sweet beauty's best,Of hand, of foot, of lip, of eye, of brow,I see their antique pen would have express'dEven such a beauty as you master now.So all their praises are but propheciesOf this our time, all you prefiguring;And, for they look'd but with divining eyes,They had not skill enough your worth to sing:For we, which now behold these present days,Had eyes to wonder, but lack tongues to praise.""")sonnet.parse()print(sonnet.summary())
One call each way: scansion per line, rhyme letters, and the estimated schema. This is Sonnet 106, whose third quatrain rhymes prophecies/eyes and prefiguring/sing — slant rhymes (identical codas, drifted vowels) that a plain phonetic distance can’t hear but the 2-D rhyme classifier can; the sonnets exploration reads that story closely.
Reading texts
A Text can come from a string, a file or URL, or collapse to a single line:
from pathlib import Path# the Shakespeare corpus bundled with the repo (fall back to GitHub)_rel = Path('corpora') /'corppoetry_en'/'en.shakespeare.txt'_root =next((p for p in [Path.cwd(), *Path.cwd().parents]if (p / _rel).exists()), None)corpus_fn = (str(_root / _rel) if _root else'https://raw.githubusercontent.com/quadrismegistus/prosodic''/master/corpora/corppoetry_en/en.shakespeare.txt')short = prosodic.Text("A horse, a horse, my kingdom for a horse!")shaksonnets = prosodic.Text(fn=corpus_fn)line = prosodic.Text("Shall I compare thee to a summer's day?").line1print(f"short: {len(short.lines)} line(s)")print(f"sonnets: {len(shaksonnets.lines):,} lines, "f"{len(shaksonnets.stanzas):,} stanzas")print(f"line: {line}")
short: 1 line(s)
sonnets: 2,155 lines, 154 stanzas
line: Line(num=1, txt="Shall I compare thee to a summer's day?")
The hierarchy
Text objects form a lazily-built tree: stanzas → lines → word tokens → word forms (pronunciations) → syllables → phonemes.
print(f"sonnet has {len(sonnet.stanzas)} stanza, {len(sonnet.lines)} lines")print(f"line 1 has {len(sonnet.lines[0].wordtokens)} word tokens")wordform = sonnet.line1.wordtokens[3].wordform # "chronicle"print(f"\n{wordform}")for syll in wordform.syllables:print(f" {syll.txt!r:8} ipa={syll.ipa!r:8} "f"stressed={syll.is_stressed} heavy={syll.is_heavy}")
sonnet has 1 stanza, 14 lines
line 1 has 7 word tokens
WordForm(num=1, txt='chronicle', ipa_origin='dict')
'chro' ipa="'krɑ" stressed=True heavy=False
'ni' ipa='nɪ' stressed=False heavy=False
'cle' ipa='kəl' stressed=False heavy=True
DataFrame view
Underneath, the source of truth is a flat per-syllable DataFrame — entities above are built from it on demand. Every feature the parser uses is a column:
sonnet.df.head(8)
word_num
line_num
para_num
sent_num
sentpart_num
linepart_num
word_txt
is_punc
form_idx
num_forms
syll_idx
syll_ipa
syll_text
is_stressed
is_heavy
is_strong
is_weak
is_functionword
0
1
1
1
1
1
1
When
0
0
2
0
wɛn
When
False
True
False
False
True
1
1
1
1
1
1
1
When
0
1
2
0
'wɛn
When
True
True
False
False
False
2
2
1
1
1
1
1
in
0
0
2
0
ɪn
in
False
True
False
False
True
3
2
1
1
1
1
1
in
0
1
2
0
'ɪn
in
True
True
False
False
False
4
3
1
1
1
1
1
the
0
0
1
0
ðə
the
False
False
False
False
True
5
4
1
1
1
1
1
chronicle
0
0
1
0
'krɑ
chro
True
False
True
False
False
6
4
1
1
1
1
1
chronicle
0
0
1
1
nɪ
ni
False
False
False
True
False
7
4
1
1
1
1
1
chronicle
0
0
1
2
kəl
cle
False
True
False
False
False
Metrical parsing
parse() evaluates every possible scansion of each line against a set of violable constraints (w_stress, s_unstress, w_peak, unres_within/unres_across, foot_size, …), scores them by weighted violations, and keeps the Pareto frontier under harmonic bounding. With syntax=True (below), gradient phrasal-stress constraints join in. Theory and implementation: Metrical parsing.
line = prosodic.Text("Shall I compare thee to a summer's day?").line1line.parse()bp = line.best_parseprint(bp.txt)print(f"meter: {bp.meter_str} (- weak, + strong)")print(f"stress: {bp.stress_str} (- unstressed, + stressed)")print(f"score: {bp.score} (weighted violations)")print(f"foot_type: {bp.foot_type}, rising={bp.is_rising}")
shall I com PARE thee TO a SUM mer's DAY
meter: -+-+-+-+-+ (- weak, + strong)
stress: ---+---+-+ (- unstressed, + stressed)
score: 2.0 (weighted violations)
foot_type: iambic, rising=True
sonnet.parse()for l in sonnet.lines[:6]: bp = l.best_parseprint(f"L{l.num:2d}{bp.meter_str} score={bp.score:.0f} "f"ambig={len(l.parses.unbounded)}")
L 1 -+-+-+-+-+ score=1 ambig=1
L 2 -+-+-+-+-+ score=1 ambig=1
L 3 -+-+-+-+-+ score=2 ambig=3
L 4 -+-+-+-+-+ score=0 ambig=1
L 5 -+-+-+-+-+ score=2 ambig=4
L 6 -+-+-+-+-+ score=0 ambig=1
The metrical grid
grid_str() draws the best parse as a Hayes-style grid — column height is prominence, and the w/s template row makes any stress–meter mismatch visible at a glance (violating positions are starred):
print(sonnet.line1.grid_str())
* * * *
* * * *
* * * * * * * * * *
when IN the CHRO ni CLE of WA sted TIME
w s w s w s* w s w s
The parsed DataFrame
All parse results are also a flat DataFrame — one row per syllable per scansion, with per-constraint violation columns — ready for pandas:
sonnet.parsed_df.head(8)
line_num
word_num
form_idx
syll_idx
line_syll_idx
parse_idx
parse_rank
parse_score
is_best
is_bounded
...
pos_size
meter_val
syll_txt
syll_ipa
is_stressed
*w_peak
*w_stress
*s_unstress
*unres_across
*unres_within
0
1
1
0
0
0
1
1
1.0
True
False
...
1
w
When
wɛn
False
0
0
0
0
0
1
1
2
1
0
1
1
1
1.0
True
False
...
1
s
in
'ɪn
True
0
0
0
0
0
2
1
3
0
0
2
1
1
1.0
True
False
...
1
w
the
ðə
False
0
0
0
0
0
3
1
4
0
0
3
1
1
1.0
True
False
...
1
s
chro
'krɑ
True
0
0
0
0
0
4
1
4
0
1
4
1
1
1.0
True
False
...
1
w
ni
nɪ
False
0
0
0
0
0
5
1
4
0
2
5
1
1
1.0
True
False
...
1
s
cle
kəl
False
0
0
1
0
0
6
1
5
0
0
6
1
1
1.0
True
False
...
1
w
of
ʌv
False
0
0
0
0
0
7
1
6
0
0
7
1
1
1.0
True
False
...
1
s
wa
'weɪ
True
0
0
0
0
0
8 rows × 21 columns
Custom meters
The default meter allows two-syllable positions (resolutions). Constraints, weights, and position sizes are all configurable:
Parse(txt='when IN the CHRO ni CLE of WA sted TIME')
Poem-level analysis
Corpus- and poem-scale form detection (ported from the poesy package): meter classification, repeating line/syllable schemes, and rhyme.
sonnet.parse() # back to the default metermt = sonnet.meter_typeprint({k: mt[k] for k in ('foot', 'head', 'type')})print('feet scheme:', sonnet.line_scheme['combo'],' syllable scheme:', sonnet.syllable_scheme['combo'])
Rhyme is detected from sound, not spelling. Each line-final rime splits into nucleus (vowel) and coda distances, and pairs classify as 'perfect', 'slant' (consonance: identical coda, free vowel), 'assonance', or None — bands calibrated against Walker’s 1775 rhyming dictionary:
Everything above is English iambic pentameter, but neither is required. lang="de" swaps in German pronunciations (espeak-ng-driven — see Languages) and the same constraints score this line of Schiller’s Wilhelm Tell as strict alternating stress:
de = prosodic.Text("Durch diese hohle Gasse muß er kommen", lang="de")de.parse()bp = de.line1.best_parseprint(bp.txt)print(f"meter: {bp.meter_str} (- weak, + strong)")print(f"stress: {bp.stress_str} (- unstressed, + stressed)")
durch DIE se HO hle GAS se MUSS er KOM men
meter: -+-+-+-+-+- (- weak, + strong)
stress: -+-+-+-+-+- (- unstressed, + stressed)
Ternary meter needs no special mode either — anapestic feet (ww + s) are already in the candidate space, so meter_type classifies Byron’s anapestic tetrameter correctly at default weights:
_rel_byron = Path('corpora') /'corppoetry_en'/'en.byron.sennacherib.txt'_root_byron =next((p for p in [Path.cwd(), *Path.cwd().parents]if (p / _rel_byron).exists()), None)byron_fn = (str(_root_byron / _rel_byron) if _root_byron else'https://raw.githubusercontent.com/quadrismegistus/prosodic''/master/corpora/corppoetry_en/en.byron.sennacherib.txt')byron = prosodic.Text(fn=byron_fn)byron.parse()mt = byron.meter_typeline = byron.lines[1]bp = line.best_parseprint(bp.txt)print(f"meter: {bp.meter_str} (- weak, + strong)")print({k: mt[k] for k in ('foot', 'head', 'type')})
See Metrical parsing § Ternary meters and Meter.fit() below for training a model that learns a ternary signature from a corpus rather than relying on default weights.
Learning constraint weights (MaxEnt)
Meter.fit() learns constraint weights from data — a uniform target scansion or annotated lines — by Maximum-Entropy optimization, optionally split by line zones so the model can learn positional leniency:
meter = prosodic.Meter()meter.fit(sonnet, 'wswswswsws', zones=3)for name, w insorted(meter.zone_weights.items(), key=lambda x: -abs(x[1]))[:6]:print(f" {w:+.3f}{name}")
[1.65s] prosodic.parsing.maxent.MaxEntTrainer._build_line_data(): 1/14 lines had no matching scansion among parser candidates (syllable count mismatch?)
At corpus scale this recovers textbook metrics — on the full sonnets, the model learns on its own that line-initial inversion is free (w_stress weight 0 in zone 1). See the sonnets exploration for that experiment.
Phrasal stress
With syntax=True (requires pip install "prosodic[syntax]"), Prosodic computes sentence-level prominence from a dependency parse — gradient pstress/tstress values per word, where tstress = 1.0 is the sentence’s nuclear stress (see Phrasal stress for the MetricalTree lineage). The grid grows phrasal rows: the nuclear-stress word becomes the tallest column.
t = prosodic.Text("Shall I compare thee to a summer's day", syntax=True)t.parse()print(t.line1.grid_str())
*
* * *
* * *
* * *
* * * * * * * * * *
shall I com PARE thee TO a SUM mer's DAY
w s* w s w s* w s w s
from plotnine import themet.line1.grid_plot() + theme(figure_size=(8, 3))
Figure 1: The same grid as a figure: line.grid_plot() (plotnine).
Gradient prominence also feeds the parser via the w_stress_p / s_unstress_p / w_stress_t / s_unstress_t constraints — inert without syntax=True, most useful for prose rhythm and naturalness ranking.
Save and load
Parquet-backed persistence keeps the syllable DataFrame and parse results — no re-parsing on reload: