Prosodic

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.

Built by Ryan Heuser, Josh Falk, and Arto Anttila, with contributions from Sam Bowman.

Install

pip install prosodic

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 prosodic

sonnet = prosodic.Text("""When in the chronicle of wasted time
I see descriptions of the fairest wights,
And beauty making beautiful old rhyme
In 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'd
Even such a beauty as you master now.
So all their praises are but prophecies
Of 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())
  #st    #ln  parse        rhyme      #feet    #syll    #parse
-----  -----  -----------  -------  -------  -------  --------
    1      1  -+-+-+-+-+   a              5       10         1
    1      2  -+-+-+-+-+   b              5       10         1
    1      3  -+-+-+-+-+   a              5       10         3
    1      4  -+-+-+-+-+   b              5       10         1
    1      5  -+-+-+-+-+   -              5       10         4
    1      6  -+-+-+-+-+   c              5       10         1
    1      7  -+--++-+-+   -              4       10         6
    1      8  +-+-+-+-+-+  c              6       11         2
    1      9  -+-+-+-+--   -              4       10         3
    1     10  -+-+-+-+--   d              4       10         6
    1     11  -+-+-+-+-+   -              5       10         2
    1     12  -+-+-+-+-+   d              5       10         1
    1     13  -+-+-+-+-+   e              5       10         2
    1     14  -+-+-+-+-+   e              5       10         3


estimated schema
----------
meter: Iambic
feet: Pentameter
syllables: 10
rhyme: Sonnet, Shakespearean (abab cdcd efefgg)

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?").line1

print(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 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?").line1
line.parse()

bp = line.best_parse
print(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_parse
    print(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 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:

strict = prosodic.Meter(
    constraints=['w_peak', 'w_stress', 's_unstress', 'foot_size'],
    max_s=1, max_w=1,
)
sonnet.parse(meter=strict)
print(sonnet.line1.best_parse)
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 meter
mt = sonnet.meter_type
print({k: mt[k] for k in ('foot', 'head', 'type')})
print('feet scheme:', sonnet.line_scheme['combo'],
      '  syllable scheme:', sonnet.syllable_scheme['combo'])
{'foot': 'binary', 'head': 'final', 'type': 'iambic'}
feet scheme: (5,)   syllable scheme: (10,)

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:

from prosodic.analysis import nums_to_scheme

print('time/rhyme:        ', sonnet.line1.rime_type(sonnet.lines[2]))
print('prophecies/eyes:   ', sonnet.lines[8].rime_type(sonnet.lines[10]))
print('rhyme letters:     ', ''.join(nums_to_scheme(sonnet.rhyme_ids)))

rs = sonnet.rhyme_scheme
print(f"best-fit scheme:    {rs['name']} ({rs['form']}), "
      f"accuracy {rs['accuracy']:.2f}")
print('is_sonnet:', sonnet.is_sonnet,
      ' is_shakespearean_sonnet:', sonnet.is_shakespearean_sonnet)
time/rhyme:         perfect
prophecies/eyes:    slant
rhyme letters:      abab-c-c-d-dee
best-fit scheme:    Sonnet, Shakespearean (abab cdcd efefgg), accuracy 0.71
is_sonnet: True  is_shakespearean_sonnet: True

Other languages and meters

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_parse
print(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_type

line = byron.lines[1]
bp = line.best_parse
print(bp.txt)
print(f"meter:  {bp.meter_str}   (- weak, + strong)")
print({k: mt[k] for k in ('foot', 'head', 'type')})
and.his CO horts.were GLEA ming.in PUR ple.and GOLD
meter:  --+--+--+--+   (- weak, + strong)
{'foot': 'ternary', 'head': 'final', 'type': 'anapestic'}

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 in sorted(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?)
  +5.069  unres_within_z3
  +4.701  unres_across_z3
  +4.281  unres_across_z2
  +4.190  unres_within_z2
  +3.449  s_unstress_z1
  +3.043  w_stress_z3

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
df = t.df
(df[df.form_idx == 0].drop_duplicates('word_num')
   [['word_txt', 'pstress', 'tstress']].set_index('word_txt').T)
word_txt Shall I compare thee to a summer's day
pstress 0.0 0.333333 0.0 0.333333 0.0 0.0 0.0 1.0
tstress 0.285714 0.0 0.571429 0.0 0.285714 0.142857 0.571429 1.0
from plotnine import theme
t.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:

import tempfile, os, shutil
out = tempfile.mkdtemp(prefix='prosodic_demo_')
sonnet.save(out)
print('saved: ', sorted(os.listdir(out)))

loaded = prosodic.TextModel.load(out)
print('loaded:', len(loaded.lines), 'lines; parse cached?',
      loaded._cached_parsed_df is not None)
shutil.rmtree(out)
saved:  ['meta.json', 'parsed.parquet', 'syll.parquet', 'text.txt.gz']
loaded: 14 lines; parse cached? True

Web app and remote client

A hosted instance runs at prosodic.app; locally:

prosodic web        # http://127.0.0.1:8181  (see the Web App page)

Any Prosodic server also backs the remote client — the same Python interface with only requests installed locally:

import prosodic
prosodic.set_server('https://prosodic.app')

t = prosodic.Text("From fairest creatures we desire increase")
t.parse()                            # delegates to /api/parse
print(t.lines[0].best_parse.meter_str)

See Web app for the five-tab tour.

Going further

  • Shakespeare’s sonnets — a whole-corpus executable analysis: form census, ambiguity, learned weights
  • Metrical parsing — the theory (Halle/Keyser → Kiparsky → OT/HG/MaxEnt) and the vectorized parser
  • Phrasal stress — the Nuclear Stress Rule, Dozat’s MetricalTree, and the dependency-projection port
  • Architecture — how the library is put together
  • API reference — classes and functions