Daser Ecce Nunc Benedicite Notebook with CRIM Intervals
Author
Richard Freedman
Published
January 19, 2025
A Digital Supplement for “Lasso takes a Second Look: An Imitation of an Imitation Mass?”
Marcel Klinke (Heidelberg University) and Richard Freedman (Haverford College)
This digital resource supplements our study of the connections between Ludwig Daser’s four-voice motet, Ecce nunc benedicite and two related imitation masses that borrow from it: one by Daser himself and another by his music court successor, Orlando di Lasso. In it, we use CRIM Intervals, a Python library that builds upon Mike Cuthbert’s excellent music21 to reveal connections and contrasts among these pieces as a corpus.
CRIM Intervals, briefly stated, is a pattern finding engine for musical scores, with an emphasis on the kinds of melodic, harmonic, and contrapuntal patterns found in Renaissance polyphony. It has been developed as a primary data analysis tool for Citations: The Renaissance Imitation Mass (http://crimproject.org and https://sites.google.com/haverford.edu/crim-project/home), directed by Freedman in partnership with dozens of researchers and students around the world, and has been used successfully for a variety of projects put qualitative and quantitative approaches to the study of these repertories into conversation with each other. Read the results of this work via the CRIM Essays and Experiments, which assembles recent work by over two-dozen scholars and students from around the world.
Our Digital Inquiry in Brief
The core of our argument about the Masses by Daser and Lasso appear in the body of our published paper. Here we show the code behind our work, along with charts, networks, and annotated musical examples that are best represented in their interactive, digital form.
In particular you will find:
Melodic Ngrams
Activity Maps of Melodic Ngrams. Ngrams are strings of length ‘n’, in this case consisting of successions of diatonic intervals in each voice part. Here we take a selective view, focusing on ngrams that occur after a rest or section break, since these provide the clearest indication of where points of imitation occur. These ‘entry’ ngrams (as they are called in CRIM Intervals) are a reliable way of comparing the treatment of melodic ideas across the corpus. The activity maps show us where each entry ngram appears in each piece, and so allows us to see where pieces are similar (and not).
Networks of Pieces based on Shared Ngrams. Here we take a higher level view of connections between movements: nodes represent the individual movements, and edges represent the ngrams they share. Such representations can tell help is divide the corpus into ‘communities’ based on shared melodic relatedness.
Cadential Patterns
Cadence Radar Plots. These take a global view of the cadences in a set of pieces, without regard to their succession in time. They are a good way to understand the tonal ‘fingerprint’ of a piece.
Cadence Progress Plots. Now the cadences are seen in order with types and tones identified by distinctive color and shape. These show us when sets of related pieces (such as Kyrie movements) follow similar cadential plans.
Cadence Inventories. Tables that give highly detailed information about each cadence in each piece (in their order of appearance) along with a link to a rendering of the passage in graphical notation (with the structural notes highlighted for review and study.
Networks of Pieces based on Pairs of Cadences. Here we look at successive pairs of cadences to reveal connections among pieces.
From Jupyter Notebook to HTML Output
Here we use CRIM Intervals in a Jupyter Notebook, which allows us to run all our functions in a browser-like environment. There are two kinds of ‘cells’ in these notebooks:
Markdown cells (like the one you are reading), which can contain commentary of various kinds
Code cells (which variously import files, process them, and produce results)
The results of this work are in turn saved as interactive HTML (and published to the web in the form you are reading) using Quarto, a powerful system for transforming code into various publication forms. By default in the Quarto system we hide the code cells. To view the details click the interactive triangle wherever you see Code. The code cannot be run in the HTML version, but it will help you understand how the various representations are produced.
Import Python Libraries
Our first step is to import all of the relevant Python libraries needed for our project.
Code
# intervals importsfrom crim_intervals import*from crim_intervals import importScore from crim_intervals import main_objsimport crim_intervals.visualizations as viz# import other librariesfrom community import community_louvainfrom copy import deepcopyfrom IPython.display import SVGimport altair as altimport glob as globimport numpy as npimport osimport pandas as pdimport reimport networkx as nximport requests# code for Quarto and the charting toolsalt.renderers.enable('default')import plotly.io as piopio.renderers.default ="plotly_mimetype+notebook_connected"import warningswarnings.filterwarnings('ignore')
Import the MEI Files to Analyze
CRIM Intervals works with a variety of encoded scores. Here in the CRIM Project we use Music Encoding Initiative (MEI) files.
Here we find the files (as stored in a local folder of our Jupyter Hub) and identify the model (model_file) and corresponding pairs of related Mass movements (mass_files_pairs). These sets are used in subsequent steps of our analysis.
Code
file_list = glob.glob('Music_Files/*')# the modelmodel_file = file_list[-1]# function to pair the mass filesdef sort_filenames(file_list):# Sort the filenames based on the last two digits of the main partreturnsorted(file_list, key=lambda x: int(re.search(r'\d{1}$', x.rsplit('.', 1)[0]).group()))mass_files_pairs = sort_filenames(file_list[:-2])
Exploring Melodic Ngrams
Here are the settings used in this experiment. These determine the kind and length of the ngrams, along with some other basic conditions for our search. Learn more about these settings via the CRIM Intervals tutorials for ngrams.
n=2 ==> determines the number of intervals in each ngram
kind='d' ==> the type of interval–in this case diatonic
thematic = True ==> when thematic is ‘true’ we return only the ngrams that are repeated
anywhere = False ==> when anywhere is set to ‘false’ we return only the ngrams that occur after a rest or after a double bar or other sectional break
combineUnisons=False==> when combineUnisons is ‘true’ we combine repeated notes; here we do the opposite
How to Read the Activity Maps
The ngrams are shown as a kind of diagrammatic score:
Each ngram appears as a solid band of color according to the voice in which it appears. The uppermost voice it at the top of the chart; the lowest voice at the bottom (see the voicepart labels at the left). The ‘key’ at the top and right shows which color corresponds to which ngram. Note: in pieces with more than 10 different ngrams, some colors repeat or are hard to distinguish from each other.
The ngrams are arranged in sequence from left (the start of the piece) to the right (the end). The scale at the bottom of the chart shows the count of quarter-note values from start to finish. In music21 (and thus in CRIM Intervals) these ‘offset’ values help us track events in time. In the 4/2 bars used in our editions, each 8 ‘offset’ values correspond to a full bar. Thus each span of 50 offsets (as shown in the x-axis of the chart) represents about six bars of notation. Note that (as in many computational languages, the first offset is ‘0’. Also note that each piece (regardless of its total duration) is scaled so that the first offset is at the left of the chart and the last offset is at the right. Thus pieces of quite different length appear to have the same overall dimensions.
Each ngram is associated with a distinct color, and the relative placement of the ngrams among the voices can be used to imagine the kinds of contrapuntal patterns heard at each moment in the piece. Note: in pieces with more than 10 different ngrams, some colors repeat or are hard to distinguish from each other.
The activity charts are dynamic: click on a given color to isolate that ngram to see where it appears in the piece.
Melodic Ngrams in Daser’s Motet
Code
# the ngram conditionsn=2combineUnisons=Falsekind='d'thematic =Trueanywhere =False# load the model_file as CRIM Intervals objectmodel = importScore(model_file)# find entries for model according to the conditions set abovenr = model.notes(combineUnisons=combineUnisons)mel = model.melodic(df=nr, kind=kind, compound=False, unit=0, end=False)mod_mel_ngrams = model.ngrams(df=mel, n=n, exclude=['Rest'])mod_entry_ngrams = model.entries(df=mel, n=n, thematic=thematic, anywhere=anywhere, exclude=['Rest'])mod_mel_ngrams_duration = model.durations(df=mel, n=n, mask_df=mod_entry_ngrams)mod_entries_stack =list(mod_entry_ngrams.stack().unique())# print details about the piece and show the chartprint(model.metadata['composer'])print(model.metadata['title'])display(viz.plot_ngrams_heatmap(mod_entry_ngrams, mod_mel_ngrams_duration, selected_patterns=mod_entries_stack, voices=[], includeCount=True))
Ludovicus Daser
Ecce nunc benedicite
Activity Map of Ngrams in Daser’s Motet
Comparing Melodic Ngrams in the the Motet with Those in the Mass Movements
Here we produce ‘pairs’ of charts, comparing each movement of each mass with the motet–for instance we compare the motet with Daser’s Kyrie, then with Lasso’s. Then we move to the Gloria, etc.
In this case we limit the results such that only the ngrams that appear in both the motet and each given Mass movement are shown. These are maps of the matching ngrams, and thus tell us what the Mass movements share with their model.
Code
# fig-cap: "Activity Maps for the Motet compared with Each Mass Movement"# the ngram conditionsn=2combineUnisons=Falsekind='d'thematic =Trueanywhere =False# find entries for model, which is the last piece in our listmodel = importScore(model_file)nr = model.notes(combineUnisons=combineUnisons)mel = model.melodic(df=nr, kind=kind, compound=False, unit=0, end=False)mod_mel_ngrams = model.ngrams(df=mel, n=n, exclude=['Rest'])mod_entry_ngrams = model.entries(df=mel, n=n, thematic=thematic, anywhere=anywhere, exclude=['Rest'])mod_mel_ngrams_duration = model.durations(df=mel, n=n, mask_df=mod_entry_ngrams)mod_entries_stack =list(mod_entry_ngrams.stack().unique())# find entries mass movements. # here we will display the results so that the motet is compared with each Kyrie, then each Gloria, etc# in each case Lasso's Mass Movement will come first, then Daser'smass_movements = mass_files_pairsfor movement in mass_movements: m_movement = importScore(movement) nr = m_movement.notes(combineUnisons=combineUnisons) mel = m_movement.melodic(df=nr, kind=kind, compound=False, unit=0, end=False) mass_mvmt_mel_ngrams = m_movement.ngrams(df=mel, n=n) mass_mvmt_entries = m_movement.entries(df=mel, n=n, thematic=thematic, anywhere=anywhere) mass_mvmt_mel_ngrams_duration = m_movement.durations(df=mel, n=n, mask_df=mass_mvmt_entries) mass_mvmt_entries_stack = mass_mvmt_entries.stack()# find shared entries shared_entries =list(set(mass_mvmt_entries_stack).intersection(mod_entries_stack))# print model metadata and chartprint(model.metadata['composer'])print(model.metadata['title']) display(viz.plot_ngrams_heatmap(mod_entry_ngrams, mod_mel_ngrams_duration, selected_patterns=shared_entries, voices=[], includeCount=False))# # print mass movement metadata and chartprint(m_movement.metadata['composer'])print(m_movement.metadata['title']) display(viz.plot_ngrams_heatmap(mass_mvmt_entries, mass_mvmt_mel_ngrams_duration, selected_patterns=shared_entries, voices=[], includeCount=False))
Ludovicus Daser
Ecce nunc benedicite
Ludovi[cus] Daser
Daser Missa Ecce nunc Benedicite: Kyrie
Ludovicus Daser
Ecce nunc benedicite
Orlandus Lassus
Lasso Missa Ecce nunc benedicite: Kyrie
Ludovicus Daser
Ecce nunc benedicite
Ludovi[cus] Daser
Daser Missa Ecce nunc Benedicite: Gloria
Ludovicus Daser
Ecce nunc benedicite
Orlandus Lassus
Lasso Missa Ecce nunc benedicite: Gloria
Ludovicus Daser
Ecce nunc benedicite
Ludovi[cus] Daser
Daser Missa Ecce nunc Benedicite: Credo
mei.base: WARNING: Importing <tie> without @startid and @endid is not yet supported.
Ludovicus Daser
Ecce nunc benedicite
Orlandus Lassus
Lasso Missa Ecce nunc benedicite: Credo
Ludovicus Daser
Ecce nunc benedicite
Ludovi[cus] Daser
Daser Missa Ecce nunc Benedicite: Sanctus
Ludovicus Daser
Ecce nunc benedicite
Ludovi[cus] Daser
Daser Missa Ecce nunc Benedicite: Agnus Dei
Ludovicus Daser
Ecce nunc benedicite
Orlandus Lassus
Lasso Missa Ecce nunc benedicite: Agnus
Interpreting the Activity Maps
Learning to read these kinds of charts takes some time. But after careful review of them we can conclude that:
The Kyrie movements show a strikingly clear relationship to the motet, albeit with a limited number of soggetti used in each case. But they are the same soggetti in each case!
The Sanctus and Agnus movements are quite selective in their borrowing. Again, we see very similar borrowing between Daser and Lasso.
The Gloria and Credo movements are more subtle and complex in their borrowing. It is nevertheless notable that the first soggetto of the model does not figure in prominently in these movements.
A Network of Pieces based on Shared Ngrams
Now let’s look at the corpus as a network of pieces. Networks are useful for the ways in which they make connections among items visible. Remember that:
The items with the most connections will tend to appear at the center of the graph.
In this network, the nodes are the pieces and edges represent the ngrams they share.
The thickness of the edges varies with the number of shared ngrams
The colors distinguish ‘communities’ of pieces that are highly related. These are created using the Louvain Communities Detection algorithm.
As a reminder, here are the basic ngram settings used in this network:
# the ngram conditionsn=2combineUnisons=Falsekind='d'thematic =Trueanywhere =False# now we build a list of all of the ngrams in each piecelist_series = []for item in file_list: piece = importScore(item) title = piece.metadata['composer'] +": "+ piece.metadata['title']# find entries for model nr = piece.notes(combineUnisons=combineUnisons) mel = piece.melodic(df=nr, kind=kind, compound=False, unit=0, end=False) mod_mel_ngrams = piece.ngrams(df=mel, n=n, exclude=['Rest']) mod_entry_ngrams = piece.entries(df=mel, n=n, thematic=thematic, anywhere=anywhere, exclude=['Rest']) mod_entry_ngrams.fillna('') mod_entry_ngrams['title'] = title# Set the new column as the index mod_entry_ngrams.set_index(['title'], inplace=True) stacked_entries = mod_entry_ngrams.stack() list_series.append(stacked_entries)data = pd.concat(list_series)# and now, a network in which the nodes are the pieces and edges represent the ngrams they share. # the thickness of the edges varies with the number of shared ngrams# the colors distinguish 'communities' of pieces that are highly relateddf = pd.DataFrame(data)df = df.reset_index()df.drop('level_1', axis=1, inplace=True)df = df.rename(columns={0: 'ngram'})#define the function to convert tuples to stringsdef convertTuple(tup): out =""ifisinstance(tup, tuple): out ='_'.join(tup)return out # clean the tuplesdf['ngram'] = df['ngram'].apply(convertTuple)# Group by 'ngram' and extract a list of unique titles for each groupgrouped_titles = df.groupby('ngram')['title'].unique().reset_index(name='titles')# Generate all pairs of titles for each groupall_pairs = []for _, row in grouped_titles.iterrows(): pairs =list(combinations(row['titles'], 2)) all_pairs.append((row['ngram'], pairs))# Create a new DataFrame with the resultsresult_df = pd.DataFrame(all_pairs, columns=['ngram', 'title_pairs'])# remove the empty pairsdf_filtered = result_df[result_df['title_pairs'].apply(len) >0]# explode the complicated lists of tuples, effectively 'tyding' the dataexploded_df = df_filtered.explode('title_pairs')# get the counts of each pair, which provides the basis of the weightspair_counts = exploded_df['title_pairs'].value_counts()# limit to high scoring pairs (>3)pair_counts = pair_counts[pair_counts >=6]# Adding Louvain Communitiesdef add_communities(G): G = deepcopy(G) partition = community_louvain.best_partition(G) nx.set_node_attributes(G, partition, "group")return G# Create an empty NetworkX graphG = nx.Graph()# Add nodes and assign weights to edgesfor pair, count in pair_counts.items():# Directly unpacking the tuple into node1 and node2 node1, node2 = pair# Adding nodes if they don't exist alreadyif node1 notin G.nodes: G.add_node(node1)if node2 notin G.nodes: G.add_node(node2)# Adding edge with weight G.add_edge(node1, node2, weight=count)# Adjusting edge thickness based on weightsfor edge in G.edges(data=True): edge[2]['width'] = edge[2]['weight']G = add_communities(G)# set display parametersngram_map = Network(notebook=True, width="800", height="800", bgcolor="black", font_color="white")# Set the physics layout of the networkngram_map.set_options("""{"physics": {"enabled": true,"forceAtlas2Based": { "springLength": 1},"solver": "forceAtlas2Based"}}""")ngram_map.from_nx(G)# # return the networkngram_map.show("ngram_map.html")
mei.base: WARNING: Importing <tie> without @startid and @endid is not yet supported.
Network of the Daser-Lasso Corpus based on Shared Ngrams
Interpreting the Ngram Network
Networks like these can be challenging to interpret.
The thickness of the lines (edges) of connection tells us how many of the ngrams are shared between various movements (nodes). The Gloria and Credo movements share a particularly large number of connections, despite that they are also the longest movements. This suggests that the two movements are particular close.
The colors represent some latent communities of connectedness, as identified by the Louvain Community Detection Algorithm. The details of that method are too complex to detail, but the suggestion that two Kyries in turn share much with Lasso’s Gloria makes sense (since the latter movement borrows extensively from Daser’s model, and the two Kyries do, too.
The inverted form of Soggetto 3 found in Lasso’s Credo is almost certainly not part of this network, since such representations only show us things that are shared among movements, not things unique to indivdual pieces.
Read more about Network graphs and CRIM Intervals via the Tutorials pages.
Exploring Cadences
CRIM Intervals can identify Renaissance cadence types. It does this in steps:
First we find all the ‘contrapuntal’ (also called ‘modular’) ngrams in a piece; a contrapuntal ngram records the harmonic and melodic intervals that are made when two voices sound together. Learn more about contrapuntal/modular ngrams in the CRIM Intervals Tutorials
Then we compare each of these ngrams with a library cadence formulas, each of which corresponds to a particular contrapuntal pattern.
Each cadence we identify is in turn added to a summary table that reports the cadence type, the final goal tone, the specific voice roles involved, and contextual information about the place of the cadence in the piece and relative to others. The table also includes a useful link that renders the cadence in graphical notation, with the relevant active notes of the cadence highlighted in red. Click on the value in the CadType column to view a given cadence.
Learn more about the CRIM intervals cadence finder here.
You might also want to two essays produced by CRIM participants:
These plots help us see the ‘tonal signature’ of each piece. The positions around the edge of the circle represent the tones (or tones and types). The distance from the center varies with the number of cadences of that type.
Here we show two kinds of plots for each pair of movements (Kyrie, Gloria, etc) in relation to the model:
The simple plots show just the final tone of the cadences. As noted above, the distance from the center tells us how many cadences there are of each type. The resulting shapes are fairly easy to see and interpret.
The complex plots show the type of cadence as well as the final tone. The resulting spaces are quite elaborate, but they convey more detailed information about the similarities and differences among the pieces.
In all cases the overall impression is more important than the precise details of counts, since the cadence method sometimes results in both false positives (things we would not accept as examples of the given type) and false negatives (legitimate cadences that the tool could not identify).
If combinedType is True you will see all the cadence subtype details. If displayAll is True you will see all possible cadence tones.
The Kyrie plots show a very close correspondence between the proportion and tone of cadences in each Kyrie and in the model.
In the Gloria movements, Daser’s cadential footprint aligns almost perfectly with that of his motet. Lasso’s differs slightly (with somewhat more G than D cadences), but follows the same overall countours in both charts.
The Credo movements are each much longer than the motet, and so their overall footprint is larger. They show the same overall shape. The similarity between the simple plots of two Mass movements is striking. But the complex cadence plot reveals that Lasso has departed significantly from Daser’s Mass and his model alike, adding cadences to A and C with new types.
The Sanctus here Lasso’s movement contains far fewer cadences than either Daser’s motet or his Mass Sanctus. But it is notable that Lasso’s Sanctus follows closely the shape of Daser’s, and not the motet.
The Agnus dei movements likewise show the affinity of the two masses to each other. The complex plot confirms how much these resemble each other, and how much they depart from the tonal shape of the motet.
Cadence Progress Plots
Here we show the cadences in each piece as they occur in time, . The various tones and types are represented via distinctive position and markings.
In these charts, each plot features three pieces: the motet and the relevant pair of movements from the two masses.
The color legend (top left) shows which cadence markers belong to which piece.
The shape legend (top right) shows the type of cadence represented by each shape.
The y-axis shows the final tone of each cadence.
The x-axis shows the relative position of each cadence from beginning (at the left) to the end (at the right).
Note that since the x-axis scale is always adjusted to match the full length of each piece, we should not expect the corresponding cadences to perfectly overlap. Instead we look for ‘parallel’ repeating patterns in different colors, as shown in this example, where we note a similar succession of colors and shapes in green, orange and blue:
screenshot_3763.png
Code
movement_ids = ['_1', '_2', '_3', '_4', '_5']model ='Music_Files/CRIM_Model_0051.mei'file_paths = glob.glob('Music_Files/*.*')# The string we're looking forfor movement_id in movement_ids:# Filter the list of file paths filtered_paths = [path for path in file_paths ifstr(movement_id) in path] filtered_paths.append(model) corpus = CorpusBase(filtered_paths) corpus.compareCadenceProgressPlots(includeType =True)
mei.base: WARNING: Importing <tie> without @startid and @endid is not yet supported.
Interpreting the Progress Plots
The progress plots contain much new information about the succession of cadences no less than their distribution.
In the case of the Kyrie movements, we see striking parallels between the mass movements (the succession of cadences to D, G, C in blue and orange).
The Gloria movements show a pair of strikingly similar Phrygian cadences to A that have now analogue in the motet.
The Credo movements show some clear connections (see the almost completely overlapping symbols in orange and blue). But Lasso’s Credo also departs from both the model and Daser’s mass in its use of cadences to A and even E. The latter is especially unexpected in the context of a G-final piece.
The Sanctus movements are rather different from each other, most obviously because of the small number of cadences in Lasso’s piece. On the other hand we notice a strong set of parallels and overlaps between Daser’s Sanctus and his motet.
The Agnus dei movements, as we have noticed in the case of the Radar Plots are quite similar to each other, and also have far fewer cadences overall than the motet.
Table of Cadences with Annotated Scores
CRIM Intervals can produce a summary of cadences with a dynamic ‘linked example’.
Click the CadType cell in each row to see that particular example rendered with red highlights indicating which patterns and voices were identified by the algorithm as the cadence in question.
Code
# We also need to load the files from GIT, for the LinkExample functionprefix ='https://raw.githubusercontent.com/CRIM-Project/CRIM-online/refs/heads/dev/crim/static/mei/MEI_4.0/'sorted_files = [model_file] + mass_files_pairsfor path in sorted_files: piece_id = os.path.basename(path) url = prefix + piece_id piece = importScore(url) cd=piece.cadences()# filter for a given type or tone# cd = cd.loc[cd['CVFs'] == 'CTb']print(piece_id)print(piece.metadata['composer'])print(piece.metadata['title']) piece.linkExamples(df=cd, piece_url=url)
CRIM_Model_0051.mei
Ludwig Daser
Ecce nunc benedicite
Here we render the pieces as a network based on their shared cadences. In this case the simple information about tone or type alone (and tone and type combined) will not be sufficient for us to understand much about the connections among compositions. A more subtle way of tracing the ‘cadential similarity’ is to find shared pairs of cadences.
In python we can do this with a few lines of code. Working with the Tone, CadType and CVFs (or ‘cadential voice functions’) columns of our results, we can:
combine these features into a single categorical ‘combined’ tag
combine successive pairs of cadences into a ‘paired_cadences’ tag with the shift() method in Pandas.
Here is what the code looks like:
# create a new column that combines data from several others, so we get a complex cadence typecorpus_cadences['combined'] = corpus_cadences['Tone'] +"_"+ corpus_cadences['CadType'] +"_"+ corpus_cadences['CVFs']# and now combine a given cadence tag with the following onecorpus_cadences['paired_cadences'] = corpus_cadences['combined'] +"_"+ corpus_cadences['combined'].shift(-1)# fill in the last cell, so there is no NAN, and that we get the final cadencecorpus_cadences['paired_cadences'].iloc[-1] = corpus_cadences['combined'].iloc[-1]
After the groupby operations, we then can return a table that shows the number of times that any pair of pieces shares one of these pairs of cadences. Here is a sample:
count
title_pairs
(Daser Missa Ecce nunc Benedicite: Sanctus, Daser Missa Ecce nunc Benedicite: Credo) 4
(Lasso Missa Ecce nunc benedicite: Kyrie, Lasso Missa Ecce nunc benedicite: Credo) 3
(Ecce nunc benedicite, Daser Missa Ecce nunc Benedicite: Credo) 3
(Daser Missa Ecce nunc Benedicite: Gloria, Ecce nunc benedicite) 2
(Lasso Missa Ecce nunc benedicite: Sanctus, Lasso Missa Ecce nunc benedicite: Credo) 2
Code
# create a new column that combines data from several others, so we get a complex cadence typecorpus_cadences['combined'] = corpus_cadences['Tone'] +"_"+ corpus_cadences['CadType'] +"_"+ corpus_cadences['CVFs']corpus_cadences['paired_cadences'] = corpus_cadences['combined'] +"_"+ corpus_cadences['combined'].shift(-1)# fill in the last cell, so there is no NA and that we get the final cadencecorpus_cadences['paired_cadences'].iloc[-1] = corpus_cadences['combined'].iloc[-1]# group the cadences by this combined tag and get the titlesgrouped_titles = corpus_cadences.groupby('paired_cadences')['Title'].unique().reset_index(name='titles')# Generate all pairs of titles for each groupall_pairs = []for _, row in grouped_titles.iterrows(): pairs =list(combinations(row['titles'], 2)) all_pairs.append((row['paired_cadences'], pairs))# Create a new DataFrame with the resultsresult_df = pd.DataFrame(all_pairs, columns=['paired_cadences', 'title_pairs'])# remove the empty pairsdf_filtered = result_df[result_df['title_pairs'].apply(len) >0]# explode the complicated lists of tuples, effectively 'tyding' the dataexploded_df = df_filtered.explode('title_pairs')# get the counts of each pair, which provides the basis of the weightspair_counts = exploded_df['title_pairs'].value_counts()# Adding Louvain Communitiesdef add_communities(G): G = deepcopy(G) partition = community_louvain.best_partition(G) nx.set_node_attributes(G, partition, "group")return G# Create an empty NetworkX graphG = nx.Graph()# Add nodes and assign weights to edgesfor pair, count in pair_counts.items():# Directly unpacking the tuple into node1 and node2 node1, node2 = pair# Adding nodes if they don't exist alreadyif node1 notin G.nodes: G.add_node(node1)if node2 notin G.nodes: G.add_node(node2)# Adding edge with weight G.add_edge(node1, node2, weight=count)# Adjusting edge thickness based on weightsfor edge in G.edges(data=True): edge[2]['width'] = edge[2]['weight']G = add_communities(G)# set display parameterscad_network = Network(notebook=True, width="800", height="800", bgcolor="black", font_color="white")# Set the physics layout of the networkcad_network.set_options("""{"physics": {"enabled": true,"forceAtlas2Based": { "springLength": 1},"solver": "forceAtlas2Based"}}""")cad_network.from_nx(G)# # return the networkcad_network.show("cad_network.html")
Interpretation of Cadence Network
The network in particular shows the centrality of the motet (which shares a number of pairs with all the other mass movements.
But note, too, how the Kyrie and Gloria movements of the masses fall into different communities of similarity. This means *they are more similar to each other than to other movements in the corpus**.