Practical: Sequential Models

# # Practical: Sequential Models

# ## Reading in a Sequence

# Write a function that extracts the MIDI pitch sequence from a music21 score and returns it as a Numpy array. Tied notes should be represented by a single pitch. If the input contains chords (notes with same onset time) it should (optionally) raise a ``RuntimeError`` or ignore them. It should be possible to provide a minimum and maximum time (in quarter beats) to specify a region of interest.
#
# *Hints:*
# - ``score.flat`` is an iterable over *all* elements in the score
# - ``m21.note.Note`` and ``m21.chord.Chord`` are base classes for single notes and chords respectively
# - use ``element.tie`` with [``m21.tie.Tie``](https://web.mit.edu/music21/doc/moduleReference/moduleTie.html) to check for tied notes
# - use ``element.offset`` to check for time constraints.
import muprocdurham as mpd
import numpy as np
import music21 as m21

mpd.seed_everything(42)
def pitch_sequence(score, ignore_chords=True, min_time=None, max_time=None):
    pitches = []
    # vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
    # traverse all elements in the score
    for element in score.flat:
        if isinstance(element, m21.note.Note) and (element.tie is None or element.tie == m21.tie.Tie("start")):
            # check for min/max time
            if min_time is not None and element.offset < min_time:
                continue
            if max_time is not None and element.offset >= max_time:
                continue
            # get MIDI pitch for single notes
            pitches.append(element.pitch.midi)
        elif isinstance(element, m21.chord.Chord):
            # ignore chords or raise error
            if ignore_chords:
                continue
            raise RuntimeError(f"Input contains chords {element} at {element.offset}")
        else:
            # ignore anything else
            continue
    # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    return np.asarray(pitches)


# You can use and extend the following function to convert pitch sequences to other types if needed.
from pitchtypes import EnharmonicPitch

def convert(sequence, to):
    if to == "pitch classes":
        return np.asarray([pitch % 12 for pitch in sequence])
    elif to == "named pitch":
        return np.asarray([EnharmonicPitch(pitch) for pitch in sequence])
    elif to == "named pitch classes":
        return np.asarray([EnharmonicPitch(pitch).to_class() for pitch in sequence])
    elif to is None:
        return sequence
    else:
        raise ValueError(f"Unknown conversion request to '{to}'")


# Test your implementation on the C Major Prelude. It should start with the MIDI pitches ``[60, 64, 67, 72, 76, 67, 72, 76, 60, 64, 67, 72, 76, 67, 72, 76, ...]`` (first two bars).
score = m21.corpus.parse('bach/bwv846.mxl')
s = pitch_sequence(score, ignore_chords=True)
# s = convert(s, to="pitch classes")
# s = convert(s, to="named pitch")
# s = convert(s, to="named pitch classes")
s


# ## Simple *n*-gram Model

# Complete the following template class for an *n*-gram model.
array([60, 64, 67, 72, 76, 67, 72, 76, 60, 64, 67, 72, 76, 67, 72, 76, 60,
       62, 69, 74, 77, 69, 74, 77, 60, 62, 69, 74, 77, 69, 74, 77, 59, 62,
       67, 74, 77, 67, 74, 77, 59, 62, 67, 74, 77, 67, 74, 77, 60, 64, 67,
       72, 76, 67, 72, 76, 60, 64, 67, 72, 76, 67, 72, 76, 60, 64, 69, 76,
       81, 69, 76, 81, 60, 64, 69, 76, 81, 69, 76, 81, 60, 62, 66, 69, 74,
       66, 69, 74, 60, 62, 66, 69, 74, 66, 69, 74, 59, 62, 67, 74, 79, 67,
       74, 79, 59, 62, 67, 74, 79, 67, 74, 79, 59, 60, 64, 67, 72, 64, 67,
       72, 59, 60, 64, 67, 72, 64, 67, 72, 57, 60, 64, 67, 72, 64, 67, 72,
       57, 60, 64, 67, 72, 64, 67, 72, 50, 57, 62, 66, 72, 62, 66, 72, 50,
       57, 62, 66, 72, 62, 66, 72, 55, 59, 62, 67, 71, 62, 67, 71, 55, 59,
       62, 67, 71, 62, 67, 71, 55, 58, 64, 67, 73, 64, 67, 73, 55, 58, 64,
       67, 73, 64, 67, 73, 53, 57, 62, 69, 74, 62, 69, 74, 53, 57, 62, 69,
       74, 62, 69, 74, 53, 56, 62, 65, 71, 62, 65, 71, 53, 56, 62, 65, 71,
       62, 65, 71, 52, 55, 60, 67, 72, 60, 67, 72, 52, 55, 60, 67, 72, 60,
       67, 72, 52, 53, 57, 60, 65, 57, 60, 65, 52, 53, 57, 60, 65, 57, 60,
       65, 50, 53, 57, 60, 65, 57, 60, 65, 50, 53, 57, 60, 65, 57, 60, 65,
       43, 50, 55, 59, 65, 55, 59, 65, 43, 50, 55, 59, 65, 55, 59, 65, 48,
       52, 55, 60, 64, 55, 60, 64, 48, 52, 55, 60, 64, 55, 60, 64, 48, 55,
       58, 60, 64, 58, 60, 64, 48, 55, 58, 60, 64, 58, 60, 64, 41, 53, 57,
       60, 64, 57, 60, 64, 41, 53, 57, 60, 64, 57, 60, 64, 42, 48, 57, 60,
       63, 57, 60, 63, 42, 48, 57, 60, 63, 57, 60, 63, 44, 53, 59, 60, 62,
       59, 60, 62, 44, 53, 59, 60, 62, 59, 60, 62, 43, 53, 55, 59, 62, 55,
       59, 62, 43, 53, 55, 59, 62, 55, 59, 62, 43, 52, 55, 60, 64, 55, 60,
       64, 43, 52, 55, 60, 64, 55, 60, 64, 43, 50, 55, 59, 65, 55, 59, 65,
       43, 50, 55, 59, 65, 55, 59, 65, 43, 51, 57, 60, 66, 57, 60, 66, 43,
       51, 57, 60, 66, 57, 60, 66, 43, 52, 55, 60, 67, 55, 60, 67, 43, 52,
       55, 60, 67, 55, 60, 67, 43, 50, 55, 60, 65, 55, 60, 65, 43, 50, 55,
       60, 65, 55, 60, 65, 43, 50, 55, 59, 65, 55, 59, 65, 43, 50, 55, 59,
       65, 55, 59, 65, 36, 48, 55, 58, 64, 55, 58, 64, 36, 48, 55, 58, 64,
       55, 58, 64, 36, 48, 53, 57, 60, 65, 60, 57, 60, 57, 53, 57, 53, 50,
       53, 50, 36, 47, 67, 71, 74, 77, 74, 71, 74, 71, 67, 71, 62, 65, 64,
       62])
class NGramModel:

    def __init__(self, n, prior_counts=0, alphabet=()):
        self.n = n                        # order of the n-gram model
        self.counts = {}                  # dict with counts for the individual n-grams
        self.prior_counts = prior_counts  # prior counts
        self.alphabet = set(alphabet)     # alphabet of symbols

    def fill_alphabet(self):
        """Fill gaps in integer alphabet"""
        for a in list(range(min(self.alphabet), max(self.alphabet) + 1)):
            self.alphabet.add(a)

    def check_n_gram(self, n_gram):
        n_gram = tuple(n_gram)
        assert len(n_gram) == self.n, f"n-gram must have length n={self.n}, but {n_gram} has length {len(n_gram)}"
        return n_gram

    def add(self, n_gram):
        """Add an *n*-gram by initialising or incrementing its count."""
        n_gram = self.check_n_gram(n_gram)
        assert len(n_gram) == self.n, \
            f"n-gram has wrong length, expected {self.n}, got {len(n_gram)}"
        self.alphabet |= set(n_gram)
        # vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
        try:
            self.counts[n_gram] += 1
        except KeyError:
            self.counts[n_gram] = 1
        # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

    def add_sequence(self, sequence):
        """Add all *n*-grams in the sequence."""
        # vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
        for start in range(0, len(sequence) - self.n + 1):
            n_gram = sequence[start:start + self.n]
            self.add(n_gram)
        # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

    def c(self, n_gram):
        """Return counts for this *n*-gram."""
        n_gram = self.check_n_gram(n_gram)
        # vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
        try:
            return self.counts[n_gram] + self.prior_counts
        except KeyError:
            return self.prior_counts
        # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

    def p(self, n_gram):
        """Return probability of the last element in the *n*-gram conditional on the first ``n-1`` elements."""
        n_gram = self.check_n_gram(n_gram)
        # vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
        norm = sum([self.c(n_gram[:-1] + (a,)) for a in self.alphabet])
        if norm == 0:
            return 1 / len(self.alphabet)
        return self.c(n_gram) / norm
        # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^


# Train a unigram model on pitch classes and plot the distribution.
#
# *Hint:* Use the provided function for plotting.
from muprocdurham.ngram import show_unigram_distribution

s = pitch_sequence(score=m21.corpus.parse('bach/bwv846.mxl'))
n_gram_model = NGramModel(n=1)

# vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
s = convert(s, "pitch classes")
n_gram_model.add_sequence(s)
n_gram_model.fill_alphabet()
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

print(n_gram_model.counts)
show_unigram_distribution(n_gram_model, names=True, pitch_classes=True, counts=False)


# Train a bigram model on the pitch sequence and plot
# 1. the matrix of counts
# 2. the matrix of transition probabilities.
#
# Experiment with different levels for the prior counts.
#
# *Hint:* Use the provided functions for creating the matrices and plotting them.
Practical Sequential Models
{(0,): 102, (4,): 61, (7,): 106, (2,): 71, (9,): 50, (5,): 59, (11,): 41, (6,): 14, (10,): 10, (1,): 4, (8,): 4, (3,): 6}
from muprocdurham.ngram import bigram_matrix_from_model, show_bigram_matrix

# vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
s = pitch_sequence(score=m21.corpus.parse('bach/bwv846.mxl'))
n_gram_model = NGramModel(n=2)
n_gram_model.add_sequence(s)
n_gram_model.fill_alphabet()
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

for prior_counts in [0, 5]:
    n_gram_model.prior_counts = prior_counts
    for mat, minmax in [
        bigram_matrix_from_model(n_gram_model, counts=True),
        bigram_matrix_from_model(n_gram_model),
    ]:
        show_bigram_matrix(mat, minmax=minmax, names=True)


# ## Exercise by Hand

# For the following sequence, count the pitch-class unigrams and bigrams and note them in a table like these:
#
Practical Sequential Models

Unigram Counts

C   | C# | D   | D# | E   | F   | F# | G   | G# | A   | A# | B   |

|----| —- |----| —- |----|—-| —- |----| —- |----| —- |----| | | | | | | | | | | | | |

%% Bigram Counts (rows: from; columns: to) —————————————

| C   | C# | D   | D# | E   | F   | F# | G   | G# | A   | A# | B   |
—- |----| —- |----| —- |----|—-| —- |----| —- |----| —- |----|
C | | | | | | | | | | | | |
C# | | | | | | | | | | | | |
D | | | | | | | | | | | | |
D# | | | | | | | | | | | | |
E | | | | | | | | | | | | |
F | | | | | | | | | | | | |
F# | | | | | | | | | | | | |
G | | | | | | | | | | | | |
G# | | | | | | | | | | | | |
A | | | | | | | | | | | | |
A# | | | | | | | | | | | | |
B | | | | | | | | | | | | |
# s = pitch_sequence(m21.corpus.parse('bach/bwv846.mxl'), max_time=4)
s = pitch_sequence(m21.converter.parse('./Take the A Train Theme.mid'))


score = m21.stream.Score()
part = m21.stream.Part()
measure = m21.stream.Measure()
for pitch in s:
    measure.append(m21.note.Note(str(EnharmonicPitch(pitch)), type="16th"))
part.append(measure)
score.append(part)
mpd.show_stream(score)


# Assume the unigram and bigram counts are used in a multi-order *n*-gram model that always uses the highest-order *n*-gram possible (the one with largest n) for predicting the next note. If the melody had been generated using this model, and ignoring the octave (i.e. treating the notes as pitch classes), what are the probabilities for the respective notes?
#
# *Hint:* You can use the output of following to check your solution.
/home/runner/work/MuProcDurham/MuProcDurham/muprocdurham/__init__.py:44: UserWarning: Cannot show score, falling back to text representation.
  warn("Cannot show score, falling back to text representation.", UserWarning)
{0.0} <music21.stream.Part 0x7f7a326d3130>
    {0.0} <music21.stream.Measure 0 offset=0.0>
        {0.0} <music21.note.Note G>
        {0.25} <music21.note.Note E>
        {0.5} <music21.note.Note G>
        {0.75} <music21.note.Note C>
        {1.0} <music21.note.Note E>
        {1.25} <music21.note.Note G#>
        {1.5} <music21.note.Note A>
        {1.75} <music21.note.Note A>
        {2.0} <music21.note.Note A#>
        {2.25} <music21.note.Note B>
        {2.5} <music21.note.Note E>
        {2.75} <music21.note.Note G>
        {3.0} <music21.note.Note F#>
        {3.25} <music21.note.Note F>
        {3.5} <music21.note.Note C#>
        {3.75} <music21.note.Note C>
        {4.0} <music21.note.Note E>
s = convert(s, "pitch classes")

unigram_model = NGramModel(n=1, alphabet=list(range(12)))
unigram_model.add_sequence(s)
show_unigram_distribution(unigram_model, names=True, pitch_classes=True, counts=True)

bigram_model = NGramModel(n=2, alphabet=list(range(12)))
bigram_model.add_sequence(s)
bigram_model.fill_alphabet()

show_bigram_matrix(*bigram_matrix_from_model(bigram_model, counts=True), show_values=True, names=True, pitch_classes=True)

for i in range(len(s)):
    unigram = (s[i],)
    p_unigram = unigram_model.p(unigram)
    if i > 0:
        bigram = s[i-1:i+1]
        p_bigram = bigram_model.p(bigram)
    else:
        bigram = ()
        p_bigram = None
    print(f"{EnharmonicPitch(s[i]).to_class()}: ({p_unigram}, {p_bigram})")


# ## Smoothing *n*-gram Model

# Use the template class below to implement a smoothing *n*-gram model.
Practical Sequential Models
G: (0.17647058823529413, None)
E: (0.23529411764705882, 0.3333333333333333)
G: (0.17647058823529413, 0.6666666666666666)
C: (0.11764705882352941, 0.3333333333333333)
E: (0.23529411764705882, 1.0)
G#: (0.058823529411764705, 0.3333333333333333)
A: (0.11764705882352941, 1.0)
A: (0.11764705882352941, 0.5)
A#: (0.058823529411764705, 0.5)
B: (0.058823529411764705, 1.0)
E: (0.23529411764705882, 1.0)
G: (0.17647058823529413, 0.6666666666666666)
F#: (0.058823529411764705, 0.3333333333333333)
F: (0.058823529411764705, 1.0)
C#: (0.058823529411764705, 1.0)
C: (0.11764705882352941, 1.0)
E: (0.23529411764705882, 1.0)
class SmoothingNGramModel:

    def __init__(self, n, prior_counts=0, alphabet=()):
        self._prior_counts = prior_counts
        self.n_gram_models = {n_: NGramModel(n=n_, prior_counts=prior_counts, alphabet=alphabet) for n_ in range(1, n + 1)}

    @property
    def prior_counts(self):
        return self._prior_counts

    @prior_counts.setter
    def prior_counts(self, value):
        self._prior_counts = value
        for model in self.n_gram_models.values():
            model.prior_counts = value

    @property
    def alphabet(self):
        return set().union(*[m.alphabet for m in self.n_gram_models.values()])

    def fill_alphabet(self):
        for model in self.n_gram_models.values():
            model.fill_alphabet()

    def add_sequence(self, sequence):
        for model in self.n_gram_models.values():
            model.add_sequence(sequence)

    def p(self, n_gram):
        context = n_gram[:-1]
        event = n_gram[-1]
        n = len(n_gram)
        w = self.weight(context)
        # vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
        n_gram_prediction = self.n_gram_models[n].p(n_gram)
        if n == 1:
            # stop recursion
            return n_gram_prediction
        else:
            # recurse and smooth
            return w * n_gram_prediction + (1 - w) * self.p(n_gram[1:])
        # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

    def weight(self, context):
        return 0.5


# Test your implementation on a simple bigram version. What effect do you observe when smoothing over bigrams and unigrams?
s = pitch_sequence(score=m21.corpus.parse('bach/bwv846.mxl'))
n_gram_model = SmoothingNGramModel(n=2)
n_gram_model.add_sequence(s)
n_gram_model.fill_alphabet()

mat, minmax = bigram_matrix_from_model(n_gram_model)
show_bigram_matrix(mat, minmax=minmax, names=True)
Practical Sequential Models

Total running time of the script: (0 minutes 2.061 seconds)

Gallery generated by Sphinx-Gallery