Skip to content

Key Profiles

profiles

Pitch class usage profiles (PCP) from the literature.

In almost all cases reported here, keys are assumed to be transpositionally equivalent, so the first (0th) entry is the tonic, and no key-specific information is given. The exception is QuinnWhite which provides key-specific data. In the key-specific case, we instead store the distributions as a tuple of tuples of distributions representing each individual key profile.

The profiles here provide the values exactly as reported in the literature. Where a profile does not sum to 1, an additional "_sum" entry is provided with that normalisation.

The profiles appear below in approximately chronological order. For reference, the alphabetical ordering is:

  • AardenEssen
  • AlbrechtShanahan
  • BellmanBudge
  • deClerqTemperley
  • KrumhanslKessler
  • KrumhanslSchmuckler
  • PrinceSchumuckler
  • QuinnWhite
  • SappSimple
  • TemperleyKostkaPayne
  • TemperleyDeClerq
  • Vuvan
  • VuvanHughes

The variable source_list contains a list of all of these profiles.

Each profile is a dataclass object with the following attributes:

  • name (str) - the class name
  • about (str) - a short description
  • literature (str) - reference to the relevant publication
  • major (tuple[float]) - original weights for 12 pitch classes, major keys (in KrumhanslKessler, KrumhanslSchmuckler, AardenEssen, BellmanBudge, TemperleyKostkaPayne, Sapp, AlbrechtShanahan only)
  • major_sum (tuple[float]) - weights that sum to 1, major keys (in KrumhanslKessler, KrumhanslSchmuckler, AardenEssen, BellmanBudge, TemperleyKostkaPayne, Sapp only)
  • minor (tuple[float]) - original weights for 12 pitch classes, minor keys (in KrumhanslKessler, KrumhanslSchmuckler, AardenEssen, BellmanBudge, TemperleyKostkaPayne, Sapp, AlbrechtShanahan only)
  • minor_sum (tuple[float]) - weights that sum to 1, minor keys (in KrumhanslKessler, KrumhanslSchmuckler, AardenEssen, BellmanBudge, TemperleyKostkaPayne, Sapp only)
  • natural_minor (tuple[float]) - original weights, natural minor keys (in Vuvan only)
  • natural_minor_sum (tuple[float]) - weights that sum to 1, natural minor keys (in Vuvan only)
  • harmonic_minor (tuple[float]) - original weights, harmonic minor keys (in Vuvan only)
  • harmonic_minor_sum (tuple[float]) - weights that sum to 1, harmonic minor keys (in Vuvan only)
  • melodic_minor (tuple[float]) - original weights, melodic minor keys (in Vuvan only)
  • melodic_minor_sum (tuple[float]) - weights that sum to 1, melodic minor keys (in Vuvan only)
  • roots (tuple[float]) - original weights that sum to 1, chord roots in rock harmony (in DeClerqTemperley only)
  • melody_major (tuple[float]) - original weights that sum to 1, major melodies (in TemperleyDeClerq only)
  • melody_minor (tuple[float]) - original weights that sum to 1, minor melodies (in TemperleyDeClerq only)
  • harmony_major (tuple[float]) - original weights that sum to 1, major melodies (in TemperleyDeClerq only)
  • harmony_minor (tuple[float]) - original weights that sum to 1, minor melodies (in TemperleyDeClerq only)
  • downbeat_major (tuple[float]) - original weights, major on downbeats (in PrinceSchumuckler only)
  • downbeat_major_sum (tuple[float]) - original weights that sum to 1, major on downbeats (in PrinceSchumuckler only)
  • downbeat_minor (tuple[float]) - original weights, minor on downbeats (in PrinceSchumuckler only)
  • downbeat_minor_sum (tuple[float]) - original weights that sum to 1, minor on downbeats (in PrinceSchumuckler only)
  • all_beats_major (tuple[float]) - original weights, major on all beats (in PrinceSchumuckler only)
  • all_beats_major_sum (tuple[float]) - original weights that sum to 1, major on all beats (in PrinceSchumuckler only)
  • all_beats_minor (tuple[float]) - original weights, minor on all_beats (in PrinceSchumuckler only)
  • all_beats_minor_sum (tuple[float]) - original weights that sum to 1, minor on all_beats (in PrinceSchumuckler only)
  • major_all (tuple[float]) - original weights that sum to 1, all keys (in QuinnWhite only)
  • major_0 (tuple[float]) - original weights that sum to 1, C Major (in QuinnWhite only)
  • major_1 (tuple[float]) - original weights that sum to 1, C#/Db Major (in QuinnWhite only)
  • major_2 (tuple[float]) - original weights that sum to 1, D Major (in QuinnWhite only)
  • major_3 (tuple[float]) - original weights that sum to 1, D#/Eb Major (in QuinnWhite only)
  • major_4 (tuple[float]) - original weights that sum to 1, E Major (in QuinnWhite only)
  • major_5 (tuple[float]) - original weights that sum to 1, F Major (in QuinnWhite only)
  • major_6 (tuple[float]) - original weights that sum to 1, F#/Gb Major (in QuinnWhite only)
  • major_7 (tuple[float]) - original weights that sum to 1, G Major (in QuinnWhite only)
  • major_8 (tuple[float]) - original weights that sum to 1, G#/Ab Major (in QuinnWhite only)
  • major_9 (tuple[float]) - original weights that sum to 1, A Major, (in QuinnWhite only)
  • major_10 (tuple[float]) - original weights that sum to 1, A#/Bb Major (in QuinnWhite only)
  • major_11 (tuple[float]) - original weights that sum to 1, B Major (in QuinnWhite only)
  • minor_all (tuple[float]) - original weights that sum to 1, all keys (in QuinnWhite only)
  • minor_0 (tuple[float]) - original weights that sum to 1, C Minor (in QuinnWhite only)
  • minor_1 (tuple[float]) - original weights that sum to 1, C#/Db Minor (in QuinnWhite only)
  • minor_2 (tuple[float]) - original weights that sum to 1, D Minor (in QuinnWhite only)
  • minor_3 (tuple[float]) - original weights that sum to 1, D#/Eb Minor (in QuinnWhite only)
  • minor_4 (tuple[float]) - original weights that sum to 1, E Minor (in QuinnWhite only)
  • minor_5 (tuple[float]) - original weights that sum to 1, F Minor (in QuinnWhite only)
  • minor_6 (tuple[float]) - original weights that sum to 1, F#/Gb Minor (in QuinnWhite only)
  • minor_7 (tuple[float]) - original weights that sum to 1, G Minor (in QuinnWhite only)
  • minor_8 (tuple[float]) - original weights that sum to 1, G#/Ab Minor (in QuinnWhite only)
  • minor_9 (tuple[float]) - original weights that sum to 1, A Minor, (in QuinnWhite only)
  • minor_10 (tuple[float]) - original weights that sum to 1, A#/Bb Minor (in QuinnWhite only)
  • minor_11 (tuple[float]) - original weights that sum to 1, B Minor (in QuinnWhite only)
  • classical (tuple[float]) - original weights for 12 pitch classes, all classical keys (in VuvanHuges only)
  • classical_sum (tuple[float]) - weights that sum to 1, all classical keys (in VuvanHuges only)
  • rock (tuple[float]) - original weights for 12 pitch classes, all rock keys (in VuvanHuges only)
  • rock_sum (tuple[float]) - weights that sum to 1, all rock keys (in VuvanHuges only)

Author: Mark Gotham, 2021, Huw Cheston, 2025

REFERENCE

Gotham et al. "What if the 'When' Implies the 'What'?". ISMIR, 2021 (see README.md)


PitchProfile

PitchProfile(
    name: str,
    profile_tuple: Union[
        Tuple[float, ...], Tuple[Tuple[float, ...], ...]
    ],
)

Bases: Distribution

A set of weights for each pitch class denoting the expected frequency of pitches for collections of notes (typically songs, can be chords) of a given key.

We provide methods to allow users to obtain or visualize this information in a useful state.

Definitions: Define a canonical order of pitches as the order of pitches specified in relative chromatic degree.

In our implementation, a pitch profile is a collection of pitch class distributions stored in a canonical form convenient for conversion into other useful forms, whether to provide methods in a useful state or for custom visualization. We store the pitch profile canonically in one of two forms:

  1. In the transpositionally equivalent case, we store the data as a list of 12 (float) weights beginning with the tonic.
  2. In the case of profiles that are not transpositionally equivalent, there is are weights for each key which are collectively stored as a list of 12 key profiles, each a list of 12 weights. Each profile begins with the tonic. Therefore data[2][5] represents the 5th weight (for pitch class G) in the 2nd key (D).

For visualization, our design envisions the following use-cases:

  1. Compare and contrast data within a single PitchProfile object, especially between different key profiles beginning at their respecive tonic. Hence, our custom plot method allows a list of keys to plot the data side by side in a heatmap.
  2. Compare and contrast PitchProfile data with other pitch-class distributions, hence why we also satisfy the specifications for both singular plot and multiple plots from its parent class Distribution.

Attributes:

  • _profile_label (str, class attribute) –

    histogram label for 1-D histogram (x-axis), or representing the key of the current profile in the 2-D heatmap (y-axis)

  • _data_cats_2d (List[int], class attribute) –

    data categories for the 2-D heatmap, which are labelled in terms of relative chromatic degree

  • _data_labels (List[str], class attribute) –

    possible data labels, Relative Chromatic Degree for 2-D case (x-axis), and Weights for 1-D case (y-axis)

Source code in amads/pitch/key/profiles.py
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
def __init__(
    self,
    name: str,
    profile_tuple: Union[Tuple[float, ...], Tuple[Tuple[float, ...], ...]],
):
    if not PitchProfile._check_init_data_integrity(profile_tuple):
        raise ValueError(f"invalid profile tuple {profile_tuple}")
    profile_data = None
    profile_shape = None
    dist_type = None

    x_cats = None
    x_label = None
    y_cats = None
    y_label = None
    if isinstance(profile_tuple[0], float):
        profile_data = list(profile_tuple)
        profile_shape = [len(profile_data)]
        dist_type = "symmetric_key_profile"
        x_cats = CHROMATIC_NAMES
        x_label = PitchProfile._profile_label
        y_cats = None
        y_label = PitchProfile._data_labels[1]

    elif isinstance(profile_tuple[0], tuple):
        # convert data from tonic-first to non-rotated canonical order
        profile_data = [
            elem[-idx:] + elem[:-idx]  # type: ignore
            for idx, elem in enumerate(profile_tuple)
        ]
        profile_shape = [len(profile_data), len(profile_data[0])]
        dist_type = "asymmetric_key_profile"
        x_cats = PitchProfile._data_cats_2d
        x_label = PitchProfile._data_labels[0]
        y_cats = CHROMATIC_NAMES
        y_label = PitchProfile._profile_label

    else:
        raise ValueError(f"invalid profile tuple {profile_tuple}")
    super().__init__(
        name,
        profile_data,
        dist_type,
        profile_shape,
        x_cats,  # type: ignore (strings are ok)
        x_label,
        y_cats,  # type: ignore (strings are ok)
        y_label,
    )

Functions

plot

plot(
    color: Optional[str] = None,
    option: Optional[str] = None,
    show: bool = True,
    fig: Optional[Figure] = None,
    ax: Optional[Axes] = None,
) -> Figure

Virtual plot function for Distribution. Allows standalone plotting of a Distribution (when fig and ax are None), while providing enough extensibility to invoke this plot function or its overwritten variants for subplotting when fig and ax are provided as arguments.

Parameters:

  • color (Optional[str], default: None ) –

    Plot color string specification. In this particular plot function, it is handled in 1-D distributions and ignored in 2-D distributions. None for default option (Distribution.DEFAULT_BAR_COLOR).

  • option (Optional[str], default: None ) –

    Plot style string specification. In this particular plot function, only {"bar", "line"} are valid string arguments that will be handled in a 1-D distribution, while any argument is ignored in 2-D distributions. None for default option ("bar").

  • show (bool, default: True ) –

    Whether to call plt.show() at the end.

  • fig (Figure, default: None ) –

    Provide existing Figure to draw on; if omitted, a new figure is created.

  • ax (Axes, default: None ) –

    Provide existing axes to draw on; if omitted, a new figure and axes are created.

Raises:

  • ValueError

    A ValueError is raised if:

    • ax (axes) but not fig (Figure) is provided
    • dims is not 1 or 2
Notes

Behavior to this specific plot method:

  • 1-D: bar (default) or line when kind is "line"
  • 2-D: heatmap
Source code in amads/core/distribution.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
def plot(
    self,
    color: Optional[str] = None,
    option: Optional[str] = None,
    show: bool = True,
    fig: Optional[Figure] = None,
    ax: Optional[Axes] = None,
) -> Figure:
    """
    Virtual plot function for Distribution.
    Allows standalone plotting of a Distribution (when fig and ax are None),
    while providing enough extensibility to invoke this plot function or its
    overwritten variants for subplotting when fig and ax are provided as
    arguments.

    Parameters
    ----------
    color : Optional[str]
        Plot color string specification. In this particular plot function,
        it is handled in 1-D distributions and ignored in 2-D distributions.
        None for default option (Distribution.DEFAULT_BAR_COLOR).
    option : Optional[str]
        Plot style string specification. In this particular plot function,
        only {"bar", "line"} are valid string arguments that will be handled
        in a 1-D distribution, while any argument is ignored in 2-D
        distributions. None for default option ("bar").
    show : bool
        Whether to call ``plt.show()`` at the end.
    fig : Figure
        Provide existing Figure to draw on; if omitted, a new
        figure is created.
    ax : Axes
        Provide existing axes to draw on; if omitted, a new
        figure and axes are created.

    Raises
    ------
    ValueError
        A ValueError is raised if:

        - `ax` (axes) but not `fig` (Figure) is provided
        - `dims` is not 1 or 2

    Notes
    -----
    Behavior to this specific plot method:

    - 1-D: bar (default) or line when kind is "line"
    - 2-D: heatmap
    """
    dims = len(self.dimensions)
    if dims not in (1, 2):
        raise ValueError(
            "Unsupported number of dimensions for Distribution class"
        )

    # Figure/axes handling: either both `fig` and `ax` are provided, or
    # neither; in the latter case, create a new figure/axes pair.
    if fig is None:
        if ax is not None:
            raise ValueError("invalid figure/axis combination")
        fig, ax = plt.subplots()
    else:
        if ax is None:
            raise ValueError("invalid figure/axis combination")

    if dims == 1:
        if color is None:
            color = Distribution.DEFAULT_BAR_COLOR
        if option is None:
            option = "bar"
        x = range(len(self.x_categories))
        # 1-D distributions: draw either a bar chart or a line chart.
        if option == "bar":
            ax.bar(x, self.data, color=color)
        elif option == "line":
            ax.plot(x, self.data, color=color, marker="o")
        else:
            raise ValueError(f"unknown kind for 1D plot: {option}")

        ax.set_xticks(list(x))
        ax.set_xticklabels([str(label) for label in self.x_categories])
        ax.set_xlabel(self.x_label)
        ax.set_ylabel(self.y_label)
        ax.set_title(self.name)

    else:  # dims == 2
        # 2-D distributions: render as a heatmap with a colorbar.
        data = np.asarray(self.data)
        cax = ax.imshow(
            data, cmap="gray_r", aspect="auto", interpolation="nearest"
        )
        fig.colorbar(cax, ax=ax, label="Proportion")

        ax.set_xlabel(self.x_label)
        ax.set_ylabel(self.y_label)
        ax.set_title(self.name)

        ax.set_xticks(range(len(self.x_categories)))
        ax.set_xticklabels([str(label) for label in self.x_categories])
        if self.y_categories is not None:
            ax.set_yticks(range(len(self.y_categories)))
            ax.set_yticklabels([str(label) for label in self.y_categories])

        ax.invert_yaxis()

    fig.tight_layout()
    if show:
        plt.show()
    return fig

plot_multiple classmethod

plot_multiple(
    dists: List[Distribution],
    show: bool = True,
    options: Optional[Union[str, List[str]]] = None,
    colors: Optional[Union[str, List[str]]] = None,
) -> Optional[Figure]

Plot multiple distributions into a single Figure using vertically stacked subplots.

Returns:

  • Figure or None

    A matplotlib Figure when at least one distribution is plotted; otherwise None when dists is empty.

Parameters:

  • dists (list[Distribution]) –

    Distributions to plot. 2-D are rendered as heatmaps; 1-D below them.

  • show (bool, default: True ) –

    Whether to call plt.show() at the end.

  • options (str | list[str] | None, default: None ) –

    plot style per distribution (e.g. "bar" or "line"). If a single string is given, it is broadcast to all distributions. If None, defaults to "bar".

  • colors (str | list[str] | None, default: None ) –

    color option per distribution. If a single string is given, it is broadcast to all 1-D distributions. If None, defaults to the single color Distribution.DEFAULT_BAR_COLOR.

Notes
  • distributions are plotted in the same order they were presented in dists list
  • as long as a Distribution or inherited class has a valid plot function implemented, the relevant plot will be added to the figure at the specified axes.
  • options and colors apply to all distributions
  • Although the original plot function is only limited to option and color being used in the 1-D case, it is not to say that a class inheriting Distribution won't leverage these arguments.
  • You can pass either a list (per-series) or a single string. When a single string is provided, it will be broadcast to all inputs. For example, kinds="line" makes all 1-D plots line charts.
Source code in amads/core/distribution.py
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
@classmethod
def plot_multiple(
    cls,
    dists: List["Distribution"],
    show: bool = True,
    options: Optional[Union[str, List[str]]] = None,
    colors: Optional[Union[str, List[str]]] = None,
) -> Optional[Figure]:
    """
    Plot multiple distributions into a single Figure using vertically
    stacked subplots.

    Returns
    -------
    Figure or None
        A matplotlib Figure when at least one distribution is plotted;
        otherwise None when `dists` is empty.

    Parameters
    ----------
    dists : list[Distribution]
        Distributions to plot. 2-D are rendered as heatmaps; 1-D below them.
    show : bool
        Whether to call ``plt.show()`` at the end.
    options : str | list[str] | None
        plot style per distribution (e.g. "bar" or "line"). If a single
        string is given, it is broadcast to all distributions. If None,
        defaults to "bar".
    colors : str | list[str] | None
        color option per distribution. If a single string is given, it is
        broadcast to all 1-D distributions. If None, defaults to
        the single color Distribution.DEFAULT_BAR_COLOR.

    Notes
    -----
    - distributions are plotted in the same order they were presented in
      dists list
    - as long as a Distribution or inherited class has a valid plot function
      implemented, the relevant plot will be added to the figure at the
      specified axes.
    - `options` and `colors` apply to all distributions
    - Although the original plot function is only limited to
      `option` and `color` being used in the 1-D case, it is not to say
      that a class inheriting Distribution won't leverage these arguments.
    - You can pass either a list (per-series) or a single string. When a
      single string is provided, it will be broadcast to all inputs.
      For example, kinds="line" makes all 1-D plots line charts.
    """
    if not dists:
        return None

    # when single string, broadcast to all distributions
    options = options or ["bar"] * len(dists)
    colors = colors or [Distribution.DEFAULT_BAR_COLOR] * len(dists)
    if isinstance(options, str):
        options = [options] * len(dists)
    if isinstance(colors, str):
        colors = [colors] * len(dists)
    if len(options) != len(dists) or len(colors) != len(dists):
        raise ValueError(
            "kinds/colors must match number of distributions in list case"
        )

    # Create a vertical stack of subplots sized to total count
    fig, axes = plt.subplots(len(dists), 1, squeeze=False)
    axes = axes.ravel()
    # use an axes iterator here
    ax_iter = iter(axes)
    for d, k, c in zip(dists, options, colors):
        ax = next(ax_iter)
        d.plot(color=c, option=k, show=False, fig=fig, ax=ax)

    fig.tight_layout()
    if show:
        plt.show()
    return fig

plot_grouped_1d classmethod

plot_grouped_1d(
    dists: List[Distribution],
    show: bool = True,
    options: Optional[Union[str, List[str]]] = None,
    colors: Optional[Union[str, List[str]]] = None,
) -> Optional[Figure]

Overlay multiple 1-D distributions on a single axes.

This function draws all input 1-D distributions in one matplotlib Axes so that each category (x bin) shows a "group" of values—one per distribution. You can mix plotting styles using the kinds argument (for example, some as bars and others as lines with markers. Colors are controlled via the colors argument.

Parameters:

  • dists (list[Distribution]) –

    1-D distributions to compare in a single plot.

  • show (bool, default: True ) –

    Whether to call plt.show() at the end.

  • options (str | list[str] | None, default: None ) –

    Per-distribution plot style. Allowed values: "bar" or "line". You can provide a single string to apply to all series (broadcast), or a list with length len(dists). If None, all series default to "bar".

  • colors (str | list[str] | None, default: None ) –

    Per-distribution color list. You can provide a single string to apply to all series (broadcast), or a list with length len(dists). If None, a distinct default color palette is applied (rcParams cycle or the tab10 palette).

Returns:

  • Figure or None

    A matplotlib Figure if any distributions are plotted; None when dists is empty.

Constraints
  • Only 1-D distributions are accepted. All inputs must have the same length (number of categories) so they can be grouped per category.
  • The x/y labels and category names are taken from the first distribution in dists. Hence, this function does not support overlaying 1-D distributions with different categories and labels.
How this differs from plot_multiple
  • plot_grouped_1d overlays all 1-D distributions on a single axes to allow:
    1. per-category (bin-by-bin) comparison intuitive and compact for grouped bar graphs
    2. intuitive and compact gradient comparison for overlaid line graphs.

Since all distributions are plotted in a single plot, we can compare all plots within a single legend. - plot_multiple creates a vertical stack of subplots, one per distribution, while leveraging the plot attribute of each Distribution (and also supports 2-D heatmaps).

Source code in amads/core/distribution.py
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
@classmethod
def plot_grouped_1d(
    cls,
    dists: List["Distribution"],
    show: bool = True,
    options: Optional[Union[str, List[str]]] = None,
    colors: Optional[Union[str, List[str]]] = None,
) -> Optional[Figure]:
    """Overlay multiple 1-D distributions on a single axes.

    This function draws all input 1-D distributions in one matplotlib
    Axes so that each category (x bin) shows a "group" of values—one
    per distribution. You can mix plotting styles using the `kinds`
    argument (for example, some as bars and others as lines with
    markers. Colors are controlled via the `colors` argument.

    Parameters
    ----------
    dists : list[Distribution]
        1-D distributions to compare in a single plot.
    show : bool
        Whether to call ``plt.show()`` at the end.
    options : str | list[str] | None
        Per-distribution plot style. Allowed values: "bar" or "line".
        You can provide a single string to apply to all series (broadcast),
        or a list with length `len(dists)`. If None, all series default to
        "bar".
    colors : str | list[str] | None
        Per-distribution color list. You can provide a single string to
        apply to all series (broadcast), or a list with length `len(dists)`.
        If None, a distinct default color palette is applied (rcParams cycle
        or the tab10 palette).

    Returns
    -------
    Figure or None
        A matplotlib Figure if any distributions are plotted; None when
        `dists` is empty.

    Constraints
    -----------
    - Only 1-D distributions are accepted. All inputs must have the same
      length (number of categories) so they can be grouped per category.
    - The x/y labels and category names are taken from the first
      distribution in `dists`. Hence, this function does not support
      overlaying 1-D distributions with different categories and labels.

    How this differs from plot_multiple
    -----------------------------------
    - plot_grouped_1d overlays all 1-D distributions on a single axes
      to allow:
        1. per-category (bin-by-bin) comparison intuitive and compact
           for grouped bar graphs
        2. intuitive and compact gradient comparison for overlaid line
           graphs.

      Since all distributions are plotted in a single plot, we can
      compare all plots within a single legend.
    - plot_multiple creates a vertical stack of subplots, one per
      distribution, while leveraging the plot attribute of each
      Distribution (and also supports 2-D heatmaps).
    """
    # Validate inputs
    if not dists:
        return None
    if any(len(d.dimensions) != 1 for d in dists):
        raise ValueError(
            "All distributions must be 1-D for grouped plotting"
        )
    # number of categories for each plot in the 1d distribution
    dimension = dists[0].dimensions[0]
    if any(d.dimensions[0] != dimension for d in dists):
        raise ValueError("All 1-D distributions must have the same length")
    # labels and categories will need to be the same...
    # or else some of the data visualization for axes will be misleading
    # since this function does not support plotting multiple axes labels
    # and categories on the same plot
    if any(
        d.x_label != dists[0].x_label or d.y_label != dists[0].y_label
        for d in dists
    ):
        raise ValueError("All 1-D distributions must have same axes labels")
    if any(
        d.x_categories != dists[0].x_categories
        or d.y_categories != dists[0].y_categories
        for d in dists
    ):
        raise ValueError(
            "All 1-D distributions must have same axes categories"
        )

    # when single string, broadcast to all
    if isinstance(options, str):
        options = [options] * len(dists)
    if isinstance(colors, str):
        colors = [colors] * len(dists)
    if options is None:
        options = ["bar"] * len(dists)
    if colors is None:
        # get the default ListedColormap; get_cmap does not always
        # return an object with .colors, so we have to ignore the type:
        base_colors = plt.get_cmap("tab10").colors  # type: ignore
        colors = [
            base_colors[i % len(base_colors)] for i in range(len(dists))
        ]
    if len(options) != len(dists) or len(colors) != len(dists):
        raise ValueError(
            "kinds and colors must match number of distributions"
        )

    bar_graph_info = None
    line_graph_info = None
    # partition bar graphs and line graphs to be plotted separately
    # (so that line graphs don't each take up a bin themselves)
    if isinstance(options, list):
        bar_graph_info = [
            (dist, color)
            for dist, kind, color in zip(dists, options, colors)
            if kind == "bar"
        ]
        line_graph_info = [
            (dist, color)
            for dist, kind, color in zip(dists, options, colors)
            if kind in ("line", "plot")
        ]

    fig, ax = plt.subplots()

    # Grouped bar arithmetic (unit bar width, grouped per category)
    # must have at least 1 bin for the line plot to be valid
    n = max(len(bar_graph_info), 1)
    # bar_width does not matter here, since everything in the grouped bar
    # graph is scaled according to this variable
    bar_width = 1
    x_coords = np.arange(dimension) * bar_width * n
    bottom_half, upper_half = n // 2, n - n // 2
    width_idxes = range(-bottom_half, upper_half + 1)
    is_even_offset = ((n + 1) % 2) * bar_width / 2

    # setting plot axes
    ax.set_xticks(x_coords)
    ax.set_xticklabels([str(d) for d in dists[0].x_categories])
    ax.set_xlabel(dists[0].x_label)
    ax.set_ylabel(dists[0].y_label)
    ax.set_title("Grouped Histogram Plot for 1-D Distributions")

    for width_idx, (dist, color) in zip(width_idxes, bar_graph_info):
        x_axis = x_coords + width_idx * bar_width + is_even_offset
        ax.bar(
            x_axis, dist.data, width=bar_width, label=dist.name, color=color
        )

    for dist, color in line_graph_info:
        ax.plot(
            x_coords, dist.data, color=color, marker="o", label=dist.name
        )

    ax.legend()
    fig.tight_layout()
    if show:
        plt.show()
    return fig

_check_init_data_integrity classmethod

_check_init_data_integrity(
    data: Union[Tuple[float, ...], Tuple[Tuple[float, ...], ...]],
) -> bool

checks the integrity of the data tuple that is supplied in init

Source code in amads/pitch/key/profiles.py
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
@classmethod
def _check_init_data_integrity(
    cls, data: Union[Tuple[float, ...], Tuple[Tuple[float, ...], ...]]
) -> bool:
    """
    checks the integrity of the data tuple that is supplied in init
    """

    def allisfloat(data_tuple):
        return all(isinstance(elem, float) for elem in data_tuple)

    if len(data) != 12:
        return False
    is_valid_sym = allisfloat(data)
    if is_valid_sym:
        return True
    is_valid_asym = all(
        isinstance(elem, tuple) and len(elem) == 12 and allisfloat(elem)
        for elem in data
    )
    return is_valid_asym

normalize

normalize()

Normalize the pitch-class distributions within the PitchProfile.

For each key, the sum of all weights in the corresponding key profile is normalized to 1.

Source code in amads/pitch/key/profiles.py
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
def normalize(self):
    """
    Normalize the pitch-class distributions within the PitchProfile.

    For each key, the sum of all weights in the corresponding key profile
    is normalized to 1.
    """
    if self.distribution_type == "symmetric_key_profile":
        self.data = norm.normalize(self.data, "sum").tolist()
        return self
    else:
        self.data = [
            norm.normalize(elem, "sum").tolist() for elem in self.data
        ]
        return self

key_to_weights

key_to_weights(key: str) -> List[float]

Given a key, computes the corresponding weights for the key profile.

The key profile is rotated to the key as the tonic and organized in relative chromatic degree.

Parameters:

  • key (str) –

    pitch string denoting the key of the key profile data we want to retrieve

Returns:

  • list[float]

    weights: list of 12 floats

Source code in amads/pitch/key/profiles.py
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
def key_to_weights(self, key: str) -> List[float]:
    """
    Given a key, computes the corresponding weights for the key profile.

    The key profile is rotated to the key as the tonic and organized in
    relative chromatic degree.

    Parameters
    ----------
    key: str
        pitch string denoting the key of the key profile data we want to retrieve

    Returns
    -------
    list[float]
        weights: list of 12 floats
    """
    key_idx = None
    try:  # C -> 0, C# -> 1, D -> 2, ..., B -> 11
        key_idx = CHROMATIC_NAMES.index(key.capitalize())
    except ValueError:
        raise ValueError(
            f"invalid key {key}, expected one of {CHROMATIC_NAMES}"
        )
    assert key_idx is not None
    assert key_idx >= 0 and key_idx < 12
    if self.distribution_type == "symmetric_key_profile":
        # symmetrical case
        return self.data
    elif self.distribution_type != "asymmetric_key_profile":
        ValueError(f"invalid distribution type {self.distribution_type}")
        # asymmetrical case
    return self.data[key_idx]

as_canonical_matrix

as_canonical_matrix() -> ndarray

Computes a 12x12 matrix of the profile data.

1. The i-th row corresponds to the key profile of the
   i-th chromatic degree

2. Each row's weights begin from C and are ordered by
   relative chromatic degree)

Returns:

  • ndarray

    a 12x12 numpy matrix of floats

Source code in amads/pitch/key/profiles.py
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
def as_canonical_matrix(self) -> np.ndarray:
    """
    Computes a 12x12 matrix of the profile data.

        1. The i-th row corresponds to the key profile of the
           i-th chromatic degree

        2. Each row's weights begin from C and are ordered by
           relative chromatic degree)

    Returns
    -------
    np.ndarray
        a 12x12 numpy matrix of floats
    """
    assert self.dimensions[0] == 12
    if self.distribution_type == "symmetric_key_profile":
        # in this case, symmetric profile is transpositionally equivalent,
        # so for instance, in C# major, the pitch weights would be
        # transposed (rotated) as B -> C, C -> C#, C# -> D, ...
        profile_matrix = np.zeros((self.dimensions[0], self.dimensions[0]))
        for i in range(12):
            data = self.data[-i:] + self.data[:-i]
            profile_matrix[i] = data
        return profile_matrix
    else:
        assert self.distribution_type == "asymmetric_key_profile"
        assert self.dimensions == [12, 12]
        return np.array(self.data)

KeyProfile dataclass

KeyProfile(name: str = '', literature: str = '', about: str = '')

This is the base class for all key profiles.

Attributes: name (str): the name of the profile literature (str): citations for the profile in the literature about (str): a longer description of the profile.

Functions

__getitem__

__getitem__(key: str)

This is added for (some) backwards compatibility, allowing objects to be accessed as dictionaries using bracket notation.

Examples:

>>> kp = KrumhanslKessler()
>>> kp["name"]
'KrumhanslKessler'
Source code in amads/pitch/key/profiles.py
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
def __getitem__(self, key: str):
    """This is added for (some) backwards compatibility, allowing objects
    to be accessed as dictionaries using bracket notation.

    Examples
    --------
        >>> kp = KrumhanslKessler()
        >>> kp["name"]
        'KrumhanslKessler'
    """
    try:
        return getattr(self, key)
    # Slightly nicer error handling
    except AttributeError:
        raise AttributeError(
            f"Key Profile '{self.__str__()}' has no attribute '{key}'"
        )

transpose2c

transposes a given score to C after we've attained the maximum correlation key of the score from the krumhansl-kessler algorithm (kkcc with default parameters).

Author: Tai Nakamura, Di Wang

Reference

https://citeseerx.ist.psu.edu/document?repid=rep1&type=pdf&doi=6e06906ca1ba0bf0ac8f2cb1a929f3be95eeadfa#page=93


transpose2c

transpose2c(
    score: Score, profile_name: str = "KRUMHANSL-KESSLER"
) -> Score

returns a copy of score transposed to C-major/minor with the key from the original krumhansl-kessler algorithm (kkcc with default parameters).

Parameters:

  • score (Score) –

    The musical score to analyze.

  • profile_name (str, default: 'KRUMHANSL-KESSLER' ) –

    string argument denoting the relevant profile for key estimation

Returns:

  • Score

    a copy of the input score transposed to C-major/minor

Source code in amads/pitch/key/transpose2c.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
def transpose2c(score: Score, profile_name: str = "KRUMHANSL-KESSLER") -> Score:
    """
    returns a copy of score transposed to C-major/minor with the key from the
    original krumhansl-kessler algorithm (kkcc with default parameters).

    Parameters
    ----------
    score : Score
        The musical score to analyze.
    profile_name : str
        string argument denoting the relevant profile for key estimation

    Returns
    -------
    Score
        a copy of the input score transposed to C-major/minor
    """
    # kkcc fails when an empty score is supplied.
    # However, an empty score transposes to an empty score regardless of what key
    # you're transposing to, so we treat this as a special case here.
    if next(score.find_all(Note), None) is None:
        return score.deepcopy()
    corr_vals = kkcc(score, profile_name)

    key_idx = corr_vals.index(max(corr_vals)) % 12
    # TODO: need to use pitch_shift which is to be implemented in Score
    score_copy = score.deepcopy()
    for note in score_copy.find_all(Note):
        keynum, alt = note.pitch.as_tuple()
        # since Pitches with same alt and keynum are equivalent
        # and Pitches themselves are considered "immutable" in
        # our representation scheme
        note.pitch = Pitch(keynum - key_idx, alt)
    return score_copy

kkkey

Maximal correlation value's attribute and index pair from key_cc algorithm.

Corresponds to kkkey in miditoolbox.

Reference

https://citeseerx.ist.psu.edu/document?repid=rep1&type=pdf&doi=6e06906ca1ba0bf0ac8f2cb1a929f3be95eeadfa#page=68


kkkey

kkkey(
    score: Score,
    profile: KeyProfile = prof.krumhansl_kessler,
    attribute_names: Optional[List[str]] = ["major", "minor"],
    salience_flag: bool = False,
) -> Tuple[str, int]

Finds the pitch profile with the highest correlation value.

Within profile there are multiple profiles named by attributes. This function returns the "best" attribute (string) and the best key (int) where the int corresponds to the 12 keys in order: 0 -> C, 1 -> C#, ..., 11 -> B. (see key_cc.py for more details)

Parameters:

  • score (Score) –

    The musical score to analyze.

  • profile (KeyProfile, default: krumhansl_kessler ) –

    The key profile to use for analysis.

  • attribute_names (Optional[List[str]], default: ['major', 'minor'] ) –

    List of attribute names that denote the particular PitchProfiles within the KeyProfile to compute correlations for. See key_cc for more details

  • salience_flag (bool, default: False ) –

    indicate whether we want to turn on salience weights in key_cc

Returns:

  • tuple[str, int]

    The attribute name and key with the highest correlation coefficient.

See Also

key_cc

Source code in amads/pitch/key/kkkey.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
def kkkey(
    score: Score,
    profile: prof.KeyProfile = prof.krumhansl_kessler,
    attribute_names: Optional[List[str]] = ["major", "minor"],
    salience_flag: bool = False,
) -> Tuple[str, int]:
    """
    Finds the pitch profile with the highest correlation value.

    Within `profile` there are multiple profiles named by attributes.
    This function returns the "best" attribute (string) and the best
    key (int) where the int corresponds to the 12 keys in order:
    0 -> C, 1 -> C#, ..., 11 -> B. (see key_cc.py for more details)

    Parameters
    ----------
    score: Score
        The musical score to analyze.
    profile: Profile
        The key profile to use for analysis.
    attribute_names: Optional[List[str]]
        List of attribute names that denote the particular PitchProfiles
        within the KeyProfile to compute correlations for.
        See key_cc for more details
    salience_flag: bool
        indicate whether we want to turn on salience weights in key_cc

    Returns
    -------
    tuple[str, int]
        The attribute name and key with the highest correlation coefficient.

    See Also
    --------
    key_cc
    """
    corrcoef_pairs = key_cc(score, profile, attribute_names, salience_flag)
    # list of pairs (attribute_name, [correlation coefficients])
    max_val_iter = (coefs for (_, coefs) in corrcoef_pairs if coefs is not None)
    # This code is a little unexpected: it first searches for the maximum
    # correlation value across all attributes and keys, then finds the
    # attribute and key index corresponding to that maximum value.
    max_val = max(chain.from_iterable(max_val_iter))
    nested_coefs_iter = (
        (attr, coefs.index(max_val))
        for (attr, coefs) in corrcoef_pairs
        if coefs is not None and max_val in coefs
    )
    return next(nested_coefs_iter)

keymode

Assuming key of C, find a score's mode based on key profiles using key_cc.

This function is primarily used to estimate the mode, an attribute of a given KeyProfile collection. The key of C is assumed, and cross-correlation with profiles for other keys are ignored.

Reference

https://citeseerx.ist.psu.edu/document?repid=rep1&type=pdf&doi=6e06906ca1ba0bf0ac8f2cb1a929f3be95eeadfa#page=65


keymode

keymode(
    score: Score,
    profile: KeyProfile = prof.krumhansl_kessler,
    attribute_names: Optional[List[str]] = ["major", "minor"],
    salience_flag: bool = False,
) -> List[str]

Find the mode based on cross-correlation values.

Returns the list of mode(s) whose profile(s) have a maximal cross-correlation with the score's pitch distribution.

Parameters:

  • score (Score) –

    The musical score to analyze.

  • profile (KeyProfile, default: krumhansl_kessler ) –

    collection of profile data for different modes (attributes)

  • attribute_names (Optional[List[str]], default: ['major', 'minor'] ) –

    List of attribute names that denote the particular PitchProfiles within the KeyProfile and generally indicate different modes. See profiles.py for more details.

  • salience_flag (bool, default: False ) –

    Indicate whether we want to turn on salience weights in key_cc which is used to compute the cross-correlations.

Returns:

  • List[str]

    List of attribute names that have maximal cross-correlation with the score's profile (usually, length will be 1).

See Also

key_cc

Source code in amads/pitch/key/keymode.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
def keymode(
    score: Score,
    profile: prof.KeyProfile = prof.krumhansl_kessler,
    attribute_names: Optional[List[str]] = ["major", "minor"],
    salience_flag: bool = False,
) -> List[str]:
    """
    Find the mode based on cross-correlation values.

    Returns the list of mode(s) whose profile(s) have a maximal
    cross-correlation with the score's pitch distribution.

    Parameters
    ----------
    score: Score
        The musical score to analyze.
    profile: Profile
        collection of profile data for different modes (attributes)
    attribute_names: Optional[List[str]]
        List of attribute names that denote the particular PitchProfiles
        within the KeyProfile and generally indicate different modes.
        See profiles.py for more details.
    salience_flag: bool
        Indicate whether we want to turn on salience weights in key_cc
        which is used to compute the cross-correlations.

    Returns
    -------
    List[str]
        List of attribute names that have maximal cross-correlation with
        the score's profile (usually, length will be 1).

    See Also
    --------
    key_cc
    """

    # This algorithm is not very efficient: It computes 12 correlations
    # for each mode, but only uses one. Then, it iterates through the
    # results, once to find the maximum, and again to form a list of
    # modes that achieve that maximum.

    corrcoef_pairs = key_cc(score, profile, attribute_names, salience_flag)

    c_max_val_iter = (
        coefs[0] for (_, coefs) in corrcoef_pairs if coefs is not None
    )
    c_max_val = max(c_max_val_iter)
    keymode_attributes = [
        attr
        for (attr, coefs) in corrcoef_pairs
        if coefs is not None and coefs[0] == c_max_val
    ]
    return keymode_attributes

key_cc

Cross-correlations between pitch-class distributions and key profiles.

Author: Tai Nakamura, Di Wang

Reference

https://citeseerx.ist.psu.edu/document?repid=rep1&type=pdf&doi=6e06906ca1ba0bf0ac8f2cb1a929f3be95eeadfa#page=68 for more details


key_cc

key_cc(
    score: Score,
    profile: KeyProfile = prof.krumhansl_kessler,
    attribute_names: Optional[List[str]] = None,
    salience_flag: bool = False,
) -> List[Tuple[str, Optional[Tuple[float]]]]

Calculate the correlation coefficients with specific pitch profiles.

A score's pitch-class distribution is computed and generally, KeyProfiles come from existing data in profiles.py. Within each KeyProfile are one or more distributions, e.g. for "major" and "minor" keys, so you must specify which distributions you want correlations for. Return a list of tuples, each containing the attribute name (e.g., "major") and the corresponding 12 correlation coefficients.

When salience_flag is True, the pitch class distribution from the score (pcd) is replaced by a new one (pcd2) where each element is a weighted sum of the elements of pcd. The weights are rotated for each element. Thus, pcd2[i] = sum(pcd[j] * weight[(j + i) mod 12].

The idea here is that the perception of significance of a certain pitch in a score depends not only on its naive unweighted frequency, but also (to a lesser extent) on the frequency of functionally harmonic pitches present in the score.

Parameters:

  • score (Score) –

    The score to analyze.

  • profile (KeyProfile, default: krumhansl_kessler ) –

    The key profile to use for analysis.

  • attribute_names (Optional[List[str]], default: None ) –

    List of attribute names that denote the particular PitchProfiles within the KeyProfile to compute correlations for. An example attribute_names for profile prof.vuvan could be ["natural_minor", "harmonic_minor"], which says to compute the cross-correlation between the pitch-class distribution of the score and both prof.vuvan's natural_minor and prof.vuvan's harmonic_minor. None can be supplied when we want to specify all valid pitch profiles within a given key profile.

  • salience_flag (bool, default: False ) –

    If True, apply salience pitch-wise bias weights to the score's pitch-class distribution.

Returns:

  • List[Tuple[str, Optional[Tuple[float]]]]

    A list of tuples where each tuple contains the attribute name, from parameter attribute_names, and the corresponding 12-tuple of correlation coefficients. If an attribute name does not reference a valid data field within the specified key profile, it will yield (attribute_name, None).

Raises:

  • RuntimeError

    If the score or key profile contains equal pitch weights, resulting in correlation not being able to be computed.

Source code in amads/pitch/key/key_cc.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
def key_cc(
    score: Score,
    profile: prof.KeyProfile = prof.krumhansl_kessler,
    attribute_names: Optional[List[str]] = None,
    salience_flag: bool = False,
) -> List[Tuple[str, Optional[Tuple[float]]]]:
    """
    Calculate the correlation coefficients with specific pitch profiles.

    A score's pitch-class distribution is computed and generally,
    KeyProfiles come from existing data in profiles.py. Within each
    KeyProfile are one or more distributions, e.g. for "major" and
    "minor" keys, so you must specify which distributions you want
    correlations for.  Return a list of tuples, each containing the
    attribute name (e.g., "major") and the corresponding 12 correlation
    coefficients.

    When `salience_flag` is True, the pitch class distribution from the score
    (pcd) is replaced by a new one (pcd2) where each element is a weighted sum
    of the elements of pcd. The weights are rotated for each element. Thus,
    pcd2[i] = sum(pcd[j] * weight[(j + i) mod 12].

    The idea here is that the perception of significance of a certain pitch in
    a score depends not only on its naive unweighted frequency, but also (to a
    lesser extent) on the frequency of functionally harmonic pitches present
    in the score.

    Parameters
    ----------
    score: Score
        The score to analyze.

    profile: prof.KeyProfile
        The key profile to use for analysis.

    attribute_names: Optional[List[str]]
        List of attribute names that denote the particular PitchProfiles
        within the KeyProfile to compute correlations for. An example
        `attribute_names` for profile prof.vuvan could be
        `["natural_minor", "harmonic_minor"]`, which says to
        compute the cross-correlation between the pitch-class distribution
        of the score and both prof.vuvan's natural_minor and prof.vuvan's
        harmonic_minor. `None` can be supplied when we want to specify all
        valid pitch profiles within a given key profile.

    salience_flag: bool
        If True, apply salience pitch-wise bias weights to the score's
        pitch-class distribution.

    Returns
    -------
    List[Tuple[str, Optional[Tuple[float]]]]
        A list of tuples where each tuple contains the attribute name, from
        parameter `attribute_names`, and the corresponding 12-tuple of
        correlation coefficients. If an attribute name does not reference
        a valid data field within the specified key profile, it will yield
        `(`*attribute_name*`, None)`.

    Raises
    ------
    RuntimeError
        If the score or key profile contains equal pitch weights,
        resulting in correlation not being able to be computed.
    """

    # Get pitch-class distribution
    pcd = np.array([pitch_class_distribution_1(score, weighted=False).data])

    # Apply salience weighting if requested
    if salience_flag:
        # NOTE: this is not the weight vector,
        # the salience weights for the c-pitch in the pitch-class distribution
        # is [1, 0, 0.2, 0.17, 0.33, 0, 0, 0.5, 0, 0, 0.25, 0].
        # These weights form the first column of the 12x12 matrix salm.
        sal2 = [1, 0, 0.25, 0, 0, 0.5, 0, 0, 0.33, 0.17, 0.2, 0] * 2
        salm = np.zeros((12, 12))
        for i in range(salm.shape[0]):
            salm[i] = sal2[12 - i : 24 - i]
        pcd = np.matmul(pcd, salm.T)  # shape (1, 12)

    results = []

    true_attribute_names = attribute_names

    if true_attribute_names is None:
        true_attribute_names = [
            f.name
            for f in fields(profile)
            if f.name not in ["name", "literature", "about"]
        ]

    for attr_name in true_attribute_names:
        # ! we should probably treat the special attributes as proper attribute names
        if attr_name in ["name", "literature", "about"]:
            print(
                f"Warning! Attempting to access metadata in profile '{profile.name}"
            )
            results.append((attr_name, None))
            continue
        # Get the attribute from the profile
        attr_value = getattr(profile, attr_name, None)

        if attr_value is None:
            print(
                f"Warning: Attribute '{attr_name}' is invalid or None in profile '{profile.name}'"
            )
            results.append((attr_name, None))
            continue
        profiles_matrix = attr_value.as_canonical_matrix()
        correlations = tuple(_compute_correlations(pcd, profiles_matrix))
        if any(math.isnan(val) for val in correlations):
            raise RuntimeError(
                "key_cc has encountered either an invalid or equal weight"
                " score, or invalid pitch profile\n"
                f"correlations = {list(correlations)}\n"
                f"score pitch-class distribution = {list(pcd)}\n"
                f"profiles matrix = \n{profiles_matrix}\n"
            )
        results.append((attr_name, correlations))

    return results

kkcc

This is a wrapper for key_cc to mimic the functionality of kkcc from miditoolbox for convenience.

Author: Tai Nakamura, Di Wang

Reference

https://citeseerx.ist.psu.edu/document?repid=rep1&type=pdf&doi=6e06906ca1ba0bf0ac8f2cb1a929f3be95eeadfa#page=68


kkcc

kkcc(
    score: Score,
    profile_name: str = "KRUMHANSL-KESSLER",
    salience_flag: bool = False,
) -> Tuple[float]

kkcc wrapper on key_cc that provides the exact behavior of miditoolbox kkcc

This module:

  1. Provides 3 string options for profile names
  2. maps the profile_name option to the relevant profile and attribute name list combination for key_cc, replicating the behavior of the relevant kkcc function call in miditoolbox.

Parameters:

  • score (Score) –

    The musical score to analyze.

  • profile_name (str, default: 'KRUMHANSL-KESSLER' ) –

    String argument denoting the relevant miditoolbox string option for kkcc. Must be one of "KRUMHANSL-KESSLER", "TEMPERLEY", or "ALBRECHT-SHANAHAN".

  • salience_flag (bool, default: False ) –

    If True, apply salience weighting to the pitch-class according to Huron & Parncutt (1993).

Returns:

  • tuple[float, ...]

    This denotes the 12 major correlation coefficients and 12 minor correlation coefficients from C to B in both major and minor keys, respectively (dim=24).

Raises:

  • ValueError

    If the score is not a valid Score object or if the profile_name is invalid.

See Also

key_cc

Source code in amads/pitch/key/kkcc.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def kkcc(
    score: Score,
    profile_name: str = "KRUMHANSL-KESSLER",
    salience_flag: bool = False,
) -> Tuple[float]:
    """
    kkcc wrapper on key_cc that provides the exact behavior of miditoolbox kkcc

    This module:

      1. Provides 3 string options for profile names
      2. maps the `profile_name` option to the relevant profile and attribute
         name list combination for key_cc, replicating the behavior of
         the relevant kkcc function call in miditoolbox.

    Parameters
    ----------
    score : Score
        The musical score to analyze.
    profile_name : str
        String argument denoting the relevant miditoolbox
        string option for kkcc. Must be one of "KRUMHANSL-KESSLER",
        "TEMPERLEY", or "ALBRECHT-SHANAHAN".
    salience_flag : bool
        If True, apply salience weighting to the pitch-class
        according to Huron & Parncutt (1993).

    Returns
    -------
    tuple[float, ...]
        This denotes the 12 major correlation coefficients and 12 minor correlation
        coefficients from C to B in both major and minor keys, respectively (dim=24).

    Raises
    ------
    ValueError
        If the score is not a valid Score object or if the profile_name is invalid.

    See Also
    --------
    key_cc
    """
    if not isinstance(score, Score):
        raise ValueError("invalid score type!")
    # default is krumhansl kessler, and is what profile_name is set to by default
    profile = None
    attribute_list = None
    if profile_name == "KRUMHANSL-KESSLER":
        profile = profiles.krumhansl_kessler
        attribute_list = ["major", "minor"]
    elif profile_name == "TEMPERLEY":
        profile = profiles.temperley
        attribute_list = ["major", "minor"]
    elif profile_name == "ALBRECHT-SHANAHAN":
        profile = profiles.albrecht_shanahan
        attribute_list = ["major", "minor"]
    else:
        raise ValueError(f'profile_name = "{profile_name}" is not valid')

    # these checks are paranoia mainly to prevent future changes
    # from breaking the code after
    assert not (profile is None or attribute_list is None)
    assert isinstance(profile, profiles.KeyProfile)
    assert len(attribute_list) == 2

    corrcoef_pairs = key_cc(score, profile, attribute_list, salience_flag)
    # check integrity of corrcoef correspondences and whether or not they abide
    # to the output agreed on in key_cc
    assert len(corrcoef_pairs) == len(attribute_list)
    assert all(
        attr_name == target_attr and len(coefs) == 12
        for ((attr_name, coefs), target_attr) in zip(
            corrcoef_pairs, attribute_list
        )
    )

    # pattern match, then collect individual coefficients into
    # a single tuple to get our final corrcoefs
    nested_coefs_iter = (coefs for (_, coefs) in corrcoef_pairs)
    corrcoefs = tuple(chain.from_iterable(nested_coefs_iter))

    return corrcoefs

max_key_cc

Maximal correlation value from key_cc algorithm.

Corresponds to maxkkcc in miditoolbox

Reference

https://citeseerx.ist.psu.edu/document?repid=rep1&type=pdf&doi=6e06906ca1ba0bf0ac8f2cb1a929f3be95eeadfa#page=69


max_key_cc

max_key_cc(
    score: Score,
    profile: KeyProfile = prof.krumhansl_kessler,
    attribute_names: Optional[List[str]] = ["major", "minor"],
    salience_flag: bool = False,
) -> float

Find the maximal correlation value after calling key_cc with relevant parameters (see key_cc.py for more details)

Parameters:

  • score (Score) –

    The musical score to analyze.

  • profile (KeyProfile, default: krumhansl_kessler ) –

    The key profile to use for analysis.

  • attribute_names (Optional[List[str]], default: ['major', 'minor'] ) –

    List of attribute names that denote the particular PitchProfiles within the KeyProfile to compute correlations for. See key_cc for more details

  • salience_flag (bool, default: False ) –

    indicate whether we want to turn on salience weights in key_cc

Returns:

  • float

    the maximum correlation value computed in key_cc

Source code in amads/pitch/key/max_key_cc.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
def max_key_cc(
    score: Score,
    profile: prof.KeyProfile = prof.krumhansl_kessler,
    attribute_names: Optional[List[str]] = ["major", "minor"],
    salience_flag: bool = False,
) -> float:
    """
    Find the maximal correlation value after calling key_cc
    with relevant parameters (see key_cc.py for more details)

    Parameters
    ----------
    score: Score
        The musical score to analyze.
    profile: Profile
        The key profile to use for analysis.
    attribute_names: Optional[List[str]]
        List of attribute names that denote the particular PitchProfiles
        within the KeyProfile to compute correlations for.
        See key_cc for more details
    salience_flag: bool
        indicate whether we want to turn on salience weights in key_cc

    Returns
    -------
    float
        the maximum correlation value computed in key_cc
    """
    corrcoef_pairs = key_cc(score, profile, attribute_names, salience_flag)
    nested_coefs_iter = (
        coefs for (_, coefs) in corrcoef_pairs if coefs is not None
    )
    return max(chain.from_iterable(nested_coefs_iter))

keysom

Projection of pitch-class distribution on a self-organizing map.

Computes the projection of a pitch-class distribution on a trained self-organizing map, and visualize it in a 2-D heatmap with a custom color gradient.

Unlike the original miditoolbox implementation in matlab, the SOM here is allowed to use any key profile as long as it contains major and minor pitch profile attributes. See key/profiles.py for more details.

Warnings

(Remove this after testing and experimentation)

References

https://citeseerx.ist.psu.edu/document?repid=rep1&type=pdf&doi=6e06906ca1ba0bf0ac8f2cb1a929f3be95eeadfa#page=66 for more details

Toiviainen & Krumhansl, 2003


keysom

keysom(
    note_collection: Score,
    map: Union[KeyProfileSOM, str],
    has_legend: bool = True,
    scaled_legend: bool = True,
    font_size: Optional[Union[float, str]] = None,
    color_map: Optional[Union[str, LinearSegmentedColormap]] = None,
    show: bool = True,
) -> Tuple[ndarray, Figure]

Projects the pitch-class distribution of a note-collection to a SOM.

The SOM (self-organized map) is trained on key profile data. Returns the resulting projection matrix.

Parameters:

  • note_collection (Score) –

    Collection of notes to calculate the pitch-class distribution of and project onto the pre-trained SOM.

  • map (KeyProfileSOM) –

    A pretrained self-organizing map trained on major + minor pitch profiles. Or, a path string to a .npz file with the map.

  • has_legend (bool, default: True ) –

    Whether or not the plot should include a color legend

  • scaled_legend (bool, default: True ) –

    Whether or not the color legend scales with the projection's minimum and maximum, or (by default) scales with the trained SOM's global minimum and maximum. Use the default to get consistent color scales across multiple graphs of differing data.

  • font_size (Optional[Union[float, str]], default: None ) –

    Font size, either: (1) Font size of the labels (in points) or a string option from matplotlib (2) None for the default font size provided by matplotlib Shares the same effects as the option of the same name in the project_and_visualize method of KeyProfileSOM.

  • color_map (Optional[Union[str, LinearSegmentedColormap]], default: None ) –

    Color map describing the color gradient of the resulting visualization. Option has same functionality as the color_map argument of the project_and_visualize method of KeyProfileSOM.

  • show (bool, default: True ) –

    Whether or not we suspend execution and display the plot before returning from this function

Returns:

  • array[float]

    Returns a 2-D numpy array that contains the projection of the input data onto the self-organizing map.

  • Figure

    Matplotlib figure that contains a plot of the projection. (The axes are also accessible because they are contained within the figure.)

Source code in amads/pitch/key/keysom.py
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def keysom(
    note_collection: Score,
    map: Union[ksom.KeyProfileSOM, str],
    has_legend: bool = True,
    scaled_legend: bool = True,
    font_size: Optional[Union[float, str]] = None,
    color_map: Optional[Union[str, mcolors.LinearSegmentedColormap]] = None,
    show: bool = True,
) -> Tuple[np.ndarray, Figure]:
    """
    Projects the pitch-class distribution of a note-collection to a SOM.

    The SOM (self-organized map) is trained on key profile data.
    Returns the resulting projection matrix.

    Parameters
    ----------
    note_collection : Score
        Collection of notes to calculate the pitch-class distribution of and
        project onto the pre-trained SOM.
    map : KeyProfileSOM
        A pretrained self-organizing map trained on major + minor pitch profiles.
        Or, a path string to a .npz file with the map.
    has_legend : bool
        Whether or not the plot should include a color legend
    scaled_legend: bool
        Whether or not the color legend scales with the projection's minimum
        and maximum, or (by default) scales with the trained SOM's global
        minimum and maximum. Use the default to get consistent color scales
        across multiple graphs of differing data.
    font_size: Optional[Union[float, str]] = None,
        Font size, either:
        (1) Font size of the labels (in points) or a string option from
        matplotlib
        (2) None for the default font size provided by matplotlib
        Shares the same effects as the option of the same name in the
        project_and_visualize method of KeyProfileSOM.
    color_map: Optional[Union[str, mcolors.LinearSegmentedColormap]]
        Color map describing the color gradient of the resulting visualization.
        Option has same functionality as the `color_map` argument of the
        `project_and_visualize` method of `KeyProfileSOM`.
    show : bool
        Whether or not we suspend execution and display the plot before returning
        from this function

    Returns
    -------
    np.array[float]
        Returns a 2-D numpy array that contains the projection of the input
        data onto the self-organizing map.
    Figure
        Matplotlib figure that contains a plot of the projection. (The axes
        are also accessible because they are contained within the figure.)
    """
    target_map = None
    if isinstance(map, str):
        target_map = ksom.KeyProfileSOM.from_trained_SOM(map)
    elif isinstance(map, ksom.KeyProfileSOM):
        target_map = map
    else:
        raise ValueError("invalid map argument!")
    input = tuple(pitch_class_distribution_1(note_collection).data)
    projection, Figure = target_map.project_and_visualize(
        input, has_legend, scaled_legend, font_size, color_map, show
    )
    # a good idea would probably be to return a tuple containing projection and
    # Figure/axes
    return projection, Figure

keysomdata

Key Profile Based Self-Organizing Maps

Self-organizing maps trained on pitch class usage profiles from literature.

A self-organizing map can be trained on key profile with 'major' and 'minor' data fields. The caller can define the decay rate and neighborhood function that the profile is trained on. A projection of a pitch-class distribution (pc_projection) onto a self-organizing map given the constituent input weights (input_weights) and their corresponding pitch-class distribution weights (pcdist), is defined for all valid row, column = i, j as pc_projection[i, j, :] = sum(input_weights[i, j, k] * pcdist[k] for all k in range(12)).

When visualizing a projection of a pitch-class profile onto a trained self-organizing map, there are 24 key labels scattered across the map. The positions of these key labels (upper-case for major, lower-case for minor) are determined by the position of the best matching unit of the corresponding pitch profile in the trained map.

Reference

Toiviainen, P. & Krumhansl, C. L. (2003). Measuring and modeling real-time responses to music: the dynamics of tonality induction. Perception, 32(6), 741-766.


KeyProfileSOM

KeyProfileSOM(
    output_layer_dimensions: Tuple[int] = _default_output_dimensions,
)

The primary use-case for Key Profile SOM is to extract tonal features of a pair of major and minor pitch profiles and project them onto a 2-D map.

We provide methods to allows users to do the following:

  1. For initialization, supply the structure of the key profile SOM.

  2. For training, the following is customizeable:

    2.1 Customize the key profile used for training data. Only the 'major' and 'minor' PitchProfile attributes within the supplied key profile are used. If either attribute does not exist, a value error is raised before training.

    2.2 Customize training behavior by supplying functions denoting both the neighborhood propagation function and global decay function.

    2.3 Record a log of information to track the training process. This feature is not implemented completely.

  3. Projection of a supplied pitch-class distribution onto a trained SOM.

  4. Visualization facilities, primarily:

    4.1 Visualization of projection from a single pitch-class distribution onto the internal SOM.

    4.2 Animation of projections from a sequence of pitch-class distributions onto the internal SOM.

The internal data unique to each KeyProfileSOM object is as follows:

  1. SOM structural specifications (output layer dimensions), stored as a 2-tuple of ints specifying the width and height of the SOM output layer.

  2. The trained SOM according to the structural specifications, stored as a 3-D numpy array with shape (output_width, output_height, input_length).

  3. A list of 2-D coordinates for the trained SOM, where each list element at index i specifies the location of the ith label in the trained SOM. The coordinate is the BMU of the ith pitch's corresponding pitch-class profile in the trained SOM. Note that capital pitches denote major key pitches, while lower-case pitches denote minor key pitches.

  4. Training log containing simple training information per update instance of the SOM (for debugging purposes if logging is turned on during training).

  5. A name (string identifier) for the SOM that can be optionally specified.

Attributes:

  • _input_length (int, class attribute) –

    the number of pitches in a pitch-class distribution, or the number of tonal pitches in a western music system

  • _default_output_dimensions (Tuple[int], class attribute) –

    output dimensions for the pretrained SOM in the original MATLAB version of miditoolbox

  • _labels (List[str], class attribute) –

    list of strings denoting the pitch labels

Examples:

Load a set of pretrained weights and visualize the pitch-class distribution of a small score:

>>> from amads.core.basics import Score
>>> score = Score.from_melody([60, 62, 64, 65, 67, 69, 71, 72])
>>> from amads.pitch.pcdist1 import pitch_class_distribution_1
>>> pcdist_of_score = pitch_class_distribution_1(score)
>>> example_SOM = pretrained_weights_script()
>>> _ = example_SOM.project_and_visualize(tuple(pcdist_of_score.data),
...                                       show=False)

Train a set of weights from a key profile with 'major' and 'minor' attributes with supplied training parameters:

>>> training_profile = prof.krumhansl_kessler # from key/profiles.py
>>> test_SOM = KeyProfileSOM() # default output dimensions used
>>> _ = test_SOM.train_SOM(training_profile) # use default parameters
Source code in amads/pitch/key/keysomdata.py
346
347
348
349
350
351
352
353
354
def __init__(
    self, output_layer_dimensions: Tuple[int] = _default_output_dimensions
):
    self.SOM_output_dims = output_layer_dimensions
    self.SOM = None
    # best matching units to each of the corresponding coordinates
    self.label_coord_list = None
    self.log_info = []
    self.name = None

Functions

save_trained_SOM classmethod

save_trained_SOM(
    obj: KeyProfileSOM,
    dir_path: str = "./",
    file_name: Optional[str] = None,
)

Save a trained key profile SOM.

Raises a value exception if the object does not contain a proper trained SOM or the directory path is not valid.

Parameters:

  • obj (KeyProfileSOM) –

    Key Profile SOM object containing a trained SOM

  • dir_path (str, default: './' ) –

    Path to directory to store the trained SOM (in npz format)

  • file_name (Optional[str], default: None ) –

    Optional file name argument to save the trained SOM

Source code in amads/pitch/key/keysomdata.py
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
@classmethod
def save_trained_SOM(
    cls,
    obj: "KeyProfileSOM",
    dir_path: str = "./",
    file_name: Optional[str] = None,
):
    """
    Save a trained key profile SOM.

    Raises a value exception if the object
    does not contain a proper trained SOM or the directory path is not valid.

    Parameters
    ----------
    obj: KeyProfileSOM
        Key Profile SOM object containing a trained SOM
    dir_path: str
        Path to directory to store the trained SOM (in npz format)
    file_name: Optional[str]
        Optional file name argument to save the trained SOM
    """

    if file_name is None:
        file_name = f"{obj.name}_data.npz"

    file_path = os.path.join(dir_path, file_name)

    if obj.SOM is None or not obj.label_coord_list:
        raise ValueError("input SOM is not trained!")

    np.savez(
        file_path,
        SOM=obj.SOM,
        name=np.array(obj.name),
        label_coords=np.array(obj.label_coord_list),
    )

from_trained_SOM classmethod

from_trained_SOM(
    file_path: str = "./amads/pitch/key/KrumhanslKessler_SOM_data.npz",
) -> KeyProfileSOM

Create a new KeyProfileSOM object containing the trained KeyProfileSOM.

Data is loaded from the specified file.

Parameters:

  • file_path (str, default: './amads/pitch/key/KrumhanslKessler_SOM_data.npz' ) –

    Path to directory containing stored SOM (in npz format)

Returns:

  • KeyProfileSOM

    Key Profile SOM object containing the trained SOM from the file

Source code in amads/pitch/key/keysomdata.py
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
@classmethod
def from_trained_SOM(
    cls, file_path: str = "./amads/pitch/key/KrumhanslKessler_SOM_data.npz"
) -> "KeyProfileSOM":
    """
    Create a new KeyProfileSOM object containing the trained KeyProfileSOM.

    Data is loaded from the specified file.

    Parameters
    ----------
    file_path: str
        Path to directory containing stored SOM (in npz format)

    Returns
    -------
    KeyProfileSOM
        Key Profile SOM object containing the trained SOM from the file
    """
    load_table = np.load(file_path)
    SOM = load_table["SOM"]
    (dim0, dim1, _) = SOM.shape
    output_dims = (dim0, dim1)
    name = str(load_table["name"])
    label_coord_list = [
        tuple(coord) for coord in load_table["label_coords"]
    ]

    obj = KeyProfileSOM(output_dims)
    obj.SOM = SOM
    obj.name = name
    obj.label_coord_list = label_coord_list
    obj.vmin = np.min(obj.SOM)
    obj.vmax = np.max(obj.SOM)

    return obj

update_SOM

update_SOM(
    best_match: Tuple[int],
    input_data: array,
    idx: int,
    neighborhood: Callable[
        [Tuple[int], Tuple[int], Tuple[int], int], float
    ],
    global_decay: Callable[[int], float],
) -> KeyProfileSOM

Update the SOM on the input data based off of the best matching unit.

Uses the current global training iteration.

Parameters:

  • best_match (Tuple[int]) –

    Coordinate of the best-matching node in the output layer of the self-organizing map and its corresponding connector weights (to the input)

  • input_data (array) –

    data vector that was selected to train on for the current training iteration

  • idx (int) –

    Current training iteration in training session.

  • neighborhood (Callable[[Tuple[int], Tuple[int], Tuple[int], int], float]) –

    Neighborhood function, denoting the update rate component depending on coordinate differences to the best matching unit and training iteration.

  • global_decay (Callable[[int], float]) –

    Global decay function, denoting the update rate component dependent solely on training iteration.

Returns:

Source code in amads/pitch/key/keysomdata.py
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
def update_SOM(
    self,
    best_match: Tuple[int],
    input_data: np.array,
    idx: int,
    neighborhood: Callable[
        [Tuple[int], Tuple[int], Tuple[int], int], float
    ],
    global_decay: Callable[[int], float],
) -> "KeyProfileSOM":
    """
    Update the SOM on the input data based off of the best matching unit.

    Uses the current global training iteration.

    Parameters
    ----------
    best_match: Tuple[int]
        Coordinate of the best-matching node in the output layer of the
        self-organizing map and its corresponding connector weights
        (to the input)
    input_data: np.array
        data vector that was selected to train on for the current
        training iteration
    idx: int
        Current training iteration in training session.
    neighborhood: Callable[[Tuple[int], Tuple[int], Tuple[int], int], float]
        Neighborhood function, denoting the update rate component depending
        on coordinate differences to the best matching unit and training
        iteration.
    global_decay: Callable[[int], float]
        Global decay function, denoting the update rate component dependent
        solely on training iteration.

    Returns
    -------
    KeyProfileSOM
        Current object
    """
    if input_data.shape != (KeyProfileSOM._input_length,):
        raise ValueError(
            f"input {input_data} is of invalid shape {input_data.shape}"
        )

    dim0, dim1, input_length = self.SOM.shape
    if input_length != KeyProfileSOM._input_length:
        raise ValueError(
            f"Corrupted SOM =\n{self.SOM}\nof shape {self.SOM.shape}"
        )
    if len(best_match) != 2:
        raise ValueError(f"Invalid best matching unit coords {best_match}")

    # update all weights in the SOM based on competitive learning
    # (where the only things that truly matter in a training iteration
    # is the BMU and the input data)
    for i in range(dim0):
        for j in range(dim1):
            rate = global_decay(idx) * neighborhood(
                (i, j), best_match, self.SOM.shape, idx
            )
            self.SOM[i, j, :] = (1 - rate) * self.SOM[
                i, j, :
            ] + rate * input_data

    return self

find_best_matching_unit

find_best_matching_unit(input_data: array) -> Tuple[int]

Find best matching unit given a self-organizing map and input data.

Finds the coordinate of the output node whose weights has the smallest Euclidean distance from the input data.

Parameters:

  • input_data (array) –

    1-D data vector of input length containing the input weights

Returns:

  • Tuple[int]

    Coordinates of the node that has the connector weights with the smallest Euclidean distance to the input data

Source code in amads/pitch/key/keysomdata.py
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
def find_best_matching_unit(self, input_data: np.array) -> Tuple[int]:
    """
    Find best matching unit given a self-organizing map and input data.

    Finds the coordinate of the output node whose weights has the smallest
    Euclidean distance from the input data.

    Parameters
    ----------
    input_data: np.array
        1-D data vector of input length containing the input weights

    Returns
    -------
    Tuple[int]
        Coordinates of the node that has the connector weights with the
        smallest Euclidean distance to the input data
    """
    # input data length needs to match
    if input_data.shape != (KeyProfileSOM._input_length,):
        raise ValueError(
            f"input {input_data} is of invalid shape {input_data.shape}"
        )

    dim0, dim1, input_length = self.SOM.shape
    if input_length != KeyProfileSOM._input_length:
        raise ValueError(
            f"Corrupted SOM =\n{self.SOM}\nof shape {self.SOM.shape}"
        )

    best_i, best_j = 0, 0
    best_distance = euclidean_distance(
        input_data, self.SOM[best_i, best_j, :], False
    )
    for i in range(dim0):
        for j in range(dim1):
            distance = euclidean_distance(
                input_data, self.SOM[i, j, :], False
            )
            if best_distance > distance:
                best_i, best_j = i, j
                best_distance = distance
    return (best_i, best_j)

_data_selector

_data_selector(
    training_data: array, training_idx: int
) -> Tuple[int, array]

Internal data selector amongst training inputs represented as a list of canonical matrices (See the PitchProfile class in key/profiles.py for more detials on canonical matrices).

This training data selector very specific to amads implementation of PitchProfile (See key/profiles.py for more details on the PitchProfile class).

Parameters:

  • Training

    2-D numpy array where each row is a training data input, and each column index correspond to the indices in a chromatic scale

  • training_idx (int) –

    Index of current training iteration

Returns:

  • array[float]

    An input vector (with the weights of a normalized pitch profile) that is a 1-D numpy vector.

Source code in amads/pitch/key/keysomdata.py
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
def _data_selector(
    self, training_data: np.array, training_idx: int
) -> Tuple[int, np.array]:
    """
    Internal data selector amongst training inputs represented as a list of
    canonical matrices (See the PitchProfile class in key/profiles.py for
    more detials on canonical matrices).

    This training data selector very specific to amads implementation
    of PitchProfile (See key/profiles.py for more details on the
    PitchProfile class).

    Parameters
    ----------
    Training data:
        2-D numpy array where each row is a training data input, and each column
        index correspond to the indices in a chromatic scale
    training_idx:
        Index of current training iteration

    Returns
    -------
    np.array[float]
        An input vector (with the weights of a normalized pitch profile)
        that is a 1-D numpy vector.
    """

    num_data, input_length = training_data.shape
    if input_length != KeyProfileSOM._input_length:
        raise ValueError(
            f"invalid training data dimensions {training_data.shape},"
            " expected (num_data, 12))"
        )

    # instead of random.randrange(0, num_data), let's just see the
    # deterministic version instead...

    # first scan through all major keys in the chromatic order of circle of
    # fifths
    # then scan through all minor keys in the same chromatic order
    if training_idx % 24 < 12:
        access_idx = (training_idx * 5) % 12
        return access_idx, training_data[access_idx, :]
    else:
        access_idx = 12 + ((training_idx - 12) * 5) % 12
        return access_idx, training_data[access_idx, :]

_log_training_iteration

_log_training_iteration(
    training_idx: int, data_idx: int, bmu: Tuple[int]
)

logs a training iteration. This function can be used to visualize a training iteration.

Parameters:

  • training_idx (int) –

    current training iteration

  • data_idx (int) –

    index of the selected data row in the data matrix

  • bmu (Tuple[int]) –

    coordinate of the best matching unit for the selected data during the current training iteration.

Source code in amads/pitch/key/keysomdata.py
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
def _log_training_iteration(
    self,
    training_idx: int,
    data_idx: int,
    bmu: Tuple[int],
):
    """
    logs a training iteration. This function can be used to visualize
    a training iteration.

    Parameters
    ----------
    training_idx: int
        current training iteration

    data_idx: int
        index of the selected data row in the data matrix

    bmu: Tuple[int]
        coordinate of the best matching unit for the selected data during
        the current training iteration.
    """
    self.log_info.append((training_idx, data_idx, bmu))
    return

train_SOM

train_SOM(
    profile: KeyProfile = prof.krumhansl_kessler,
    max_iterations: int = 24 * 64,
    neighborhood: Callable[
        [Tuple[int], Tuple[int], Tuple[int], int], float
    ] = keysom_toroid_clamped,
    global_decay: Callable[
        [int], float
    ] = keysom_stepped_log_inverse_decay,
    weights_initialization: Callable[
        [Tuple[int, int, int]], array
    ] = random_SOM_init,
    log_training: bool = False,
) -> KeyProfileSOM

Train a self-organizing map using the given training data and parameters.

Parameters:

  • profile (KeyProfile, default: krumhansl_kessler ) –

    The key profile to use for analysis.

  • max_iterations (int, default: 24 * 64 ) –

    The number of iterations to train the self-organizing map for

    Neighborhood function, denoting the update rate component dependent on coordinate differences to the best matching unit and training iteration.

  • global_decay (Callable[[int], float], default: keysom_stepped_log_inverse_decay ) –

    Global decay function, denoting the update rate component dependent solely on training iteration.

  • weights_initialization (Callable[[Tuple[int, int, int]], array], default: random_SOM_init ) –

    SOM weights initialization function, returning a numpy array of the initial SOM weights dependent on its input shape.

  • log_training (bool, default: False ) –

    Indicator flag for whether or not to keep a semi-detailed log of the training process

Returns:

Source code in amads/pitch/key/keysomdata.py
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
def train_SOM(
    self,
    profile: prof.KeyProfile = prof.krumhansl_kessler,
    max_iterations: int = 24 * 64,
    neighborhood: Callable[
        [Tuple[int], Tuple[int], Tuple[int], int], float
    ] = keysom_toroid_clamped,
    global_decay: Callable[[int], float] = keysom_stepped_log_inverse_decay,
    weights_initialization: Callable[
        [Tuple[int, int, int]], np.array
    ] = random_SOM_init,
    log_training: bool = False,
) -> "KeyProfileSOM":
    """
    Train a self-organizing map using the given training data and parameters.

    Parameters
    ----------
    profile: prof.KeyProfile
        The key profile to use for analysis.
    max_iterations: int
        The number of iterations to train the self-organizing map for

        Neighborhood function, denoting the update rate component dependent
        on coordinate differences to the best matching unit and training
        iteration.
    global_decay: Callable[[int], float]
        Global decay function, denoting the update rate component dependent
        solely on training iteration.
    weights_initialization: Callable[[Tuple[int, int, int]], np.array]
        SOM weights initialization function, returning a numpy array of the
        initial SOM weights dependent on its input shape.
    log_training: bool
        Indicator flag for whether or not to keep a semi-detailed log of the
        training process

    Returns
    -------
    KeyProfileSOM
        Current object
    """
    attribute_names = ["major", "minor"]

    data_multiplier = 6

    # multiplied by 6 which is the expected value of randomly initializing
    # the values of each neuron's map weights to a random value between
    # [0, 1]
    # think of this as "normalizing" the input data to the same scale as
    # the original map weights

    # this will not affect the visualization of projections of a pitch-class
    # distribution in any way, since we can simply multiply the SOM
    # globally by the requisite multiplier after it is trained
    # in order to obtain normalized neuron weights.
    list_of_canonicals = [
        profile[attribute].normalize().as_canonical_matrix()
        * data_multiplier
        for attribute in attribute_names
    ]
    self.name = profile.name + "_SOM"

    # stack into matrix representation to satisfy _data_selector argument
    # specification
    # Additionally, training data here is ordered (per row) in chromatic
    # scale order, first in major pitch profile weights then minor pitch
    # profile weights.
    training_data = np.vstack(list_of_canonicals)

    # 12 is the input length
    # 36 is data width/feature width/something else?
    # 24 is the 12 major and 12 minor keys for the profile data
    #
    # SOM indices have been rearranged from matlab version for convenience
    # in this implementation
    self.SOM = weights_initialization(
        (*self.SOM_output_dims, KeyProfileSOM._input_length)
    )

    for training_idx in range(max_iterations):
        data_idx, data_vector = self._data_selector(
            training_data, training_idx
        )
        best_match = self.find_best_matching_unit(data_vector)
        self.update_SOM(
            best_match=best_match,
            input_data=data_vector,
            idx=training_idx,
            neighborhood=neighborhood,
            global_decay=global_decay,
        )
        if log_training:
            self._log_training_iteration(training_idx, data_idx, best_match)

    self.label_coord_list = []
    # need to find BMU to each of the key profile input vectors in the trained SOM
    # since training_data is already ordered properly, we just need to find BMU
    # over all the inputs
    for label, input in zip(KeyProfileSOM._labels, training_data):
        best_match = self.find_best_matching_unit(input)
        self.label_coord_list.append(best_match)
        # print(f"label: {label}, best_match: {best_match}")

    # setting colorbar scale here
    self.vmin = np.min(self.SOM)
    self.vmax = np.max(self.SOM)

    return self

project_input_onto_SOM

project_input_onto_SOM(input_data: array) -> array

Compute the resulting projection weights of the input on a trained SOM.

Parameters:

  • input_data (array) –

    1-D data vector of input length containing the input weights

Returns:

  • array[float]

    Returns a 2-D numpy array that contains the projection of the input data onto the self-organizing map.

Source code in amads/pitch/key/keysomdata.py
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
def project_input_onto_SOM(self, input_data: np.array) -> np.array:
    """
    Compute the resulting projection weights of the input on a trained SOM.

    Parameters
    ----------
    input_data: np.array
        1-D data vector of input length containing the input weights

    Returns
    -------
    np.array[float]
        Returns a 2-D numpy array that contains the projection of the input
        data onto the self-organizing map.
    """
    # input data length needs to match
    if input_data.shape != (KeyProfileSOM._input_length,):
        raise ValueError(
            f"input {input_data} is of invalid shape {input_data.shape}"
        )

    dim0, dim1, input_length = self.SOM.shape
    if input_length != KeyProfileSOM._input_length:
        raise ValueError(
            f"Corrupted SOM =\n{self.SOM}\nof shape {self.SOM.shape}"
        )
    # projection of input data onto current self-organizing map
    # matrix multiplication (tensor extension)
    application = self.SOM @ input_data
    assert application.shape == (dim0, dim1)
    return application

project_and_visualize

project_and_visualize(
    input: Tuple[float],
    has_legend: bool = True,
    scaled_legend: bool = False,
    font_size: Optional[Union[float, str]] = None,
    color_map: Optional[Union[str, LinearSegmentedColormap]] = None,
    show: bool = True,
) -> Tuple[array, Figure]

Project a pitch-class distribution and visualizes it.

Parameters:

  • input (Tuple[float]) –

    a singular pitch-class distribution, in which case the visualization is simply a heatmap of its projection onto the trained SOM.

  • has_legend (bool, default: True ) –

    Whether or not the plot should include a color legend

  • scaled_legend (bool, default: False ) –

    Whether or not the color legend scales with the projection's minimum and maximum, or (by default) scales with the trained SOM's global minimum and maximum.

  • font_size (Optional[Union[float, str]], default: None ) –

    Font size, either: (1) Font size of the labels (in points) or a string option from matplotlib (2) None for the default font size provided by matplotlib

  • color_map (Optional[Union[str, LinearSegmentedColormap]], default: None ) –

    Color map, either: (1) a color map provided by the matplotlib package (2) a custom linear segmented colormap (3) None for the default color scheme provided by matplotlib

  • show (bool, default: True ) –

    Whether or not we suspend execution and display the plot before returning from this function

Returns:

  • Tuple[array, Figure]

    Returns a tuple consisting of:

    1. the 2-D numpy array that contains the projection of the input data onto the self-organizing map.
    2. Matplotlib figure that contains the axes with a plot of the projection
Source code in amads/pitch/key/keysomdata.py
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
def project_and_visualize(
    self,
    input: Tuple[float],
    has_legend: bool = True,
    scaled_legend: bool = False,
    font_size: Optional[Union[float, str]] = None,
    color_map: Optional[Union[str, mcolors.LinearSegmentedColormap]] = None,
    show: bool = True,
) -> Tuple[np.array, Figure]:
    """
    Project a pitch-class distribution and visualizes it.

    Parameters
    ----------
    input: Tuple[float]
        a singular pitch-class distribution, in which case the visualization
        is simply a heatmap of its projection onto the trained SOM.
    has_legend: bool
        Whether or not the plot should include a color legend
    scaled_legend: bool
        Whether or not the color legend scales with the projection's minimum
        and maximum, or (by default) scales with the trained SOM's global
        minimum and maximum.
    font_size: Optional[Union[float, str]] = None,
        Font size, either:
        (1) Font size of the labels (in points) or a string option from
        matplotlib
        (2) None for the default font size provided by matplotlib
    color_map: Optional[Union[str, mcolors.LinearSegmentedColormap]]
        Color map, either:
         (1) a color map provided by the matplotlib package
         (2) a custom linear segmented colormap
         (3) None for the default color scheme provided by matplotlib
    show: bool
        Whether or not we suspend execution and display the plot before
        returning from this function

    Returns
    -------
    Tuple[np.array, Figure]
        Returns a tuple consisting of:

        1. the 2-D numpy array that contains the projection of the input
            data onto the self-organizing map.
        2. Matplotlib figure that contains the axes with a plot of the
            projection
    """
    if not input:
        raise ValueError("empty input not allowed")
    if isinstance(input[0], Tuple):
        raise ValueError("only takes pitch-class distribution data")
    # prep data
    projection = self.project_input_onto_SOM(np.array(input))

    dim0, dim1, _ = self.SOM.shape
    assert projection.shape == (dim0, dim1)

    fig, ax = plt.subplots()

    # there should be some thought put into the actual interpolation formula
    # cax = ax.contourf(projection)
    cax = ax.imshow(
        projection,
        aspect="auto",
        origin="lower",
        interpolation="nearest",
        cmap=color_map,
    )
    assert len(self.label_coord_list) == len(KeyProfileSOM._labels)

    # key labels in the plot
    for (i, j), label in zip(self.label_coord_list, KeyProfileSOM._labels):
        ax.text(
            j,
            i,
            label,
            ha="center",
            va="center",
            color="w",
            fontsize=font_size,
        )

    # legend
    if has_legend:
        actual_vmin, actual_vmax = self.vmin, self.vmax

        if scaled_legend:
            actual_vmin = np.min(projection)
            actual_vmax = np.max(projection)

        cax.set_clim(actual_vmin, actual_vmax)
        fig.colorbar(cax, ax=ax, label="Proportion")

    if show:
        plt.show()

    return projection, fig

project_and_animate

project_and_animate(
    input_list: List[Tuple[float]],
    has_legend: bool = True,
    font_size: Union[float, str] = 10.0,
    color_map: Optional[Union[str, LinearSegmentedColormap]] = None,
    show: bool = True,
) -> Tuple[List[array], FuncAnimation]

Animate a collection of pitch-class distributions.

Parameters:

  • input_list (List[Tuple[float]]) –

    a list of pitch-class distributions, in which case the visualization is an animation of the sequence of projections of the pitch-class distributions onto the trained SOM.

  • has_legend (bool, default: True ) –

    Whether or not the plot should include a color legend

  • font_size (Union[float, str], default: 10.0 ) –

    Font size, either:

    1. Font size of the labels (in points) or a string option from matplotlib
    2. None for the default font size provided by matplotlib
  • color_map (Optional[Union[str, LinearSegmentedColormap]], default: None ) –

    Color map, either:

    1. a color map provided by the matplotlib package
    2. a custom linear segmented colormap (see matplotlib.LinearSegmentedColormap for more details)
    3. None for the default color scheme provided by matplotlib
  • show (bool, default: True ) –

    Whether or not we suspend execution and display the plot before returning from this function

Returns:

  • Tuple[List[array], ArtistAnimation]
    1. A list of 2-D numpy arrays that contain the sequence of projections from the list of input data onto the self-organizing map
    2. The artist animation object of these data
Source code in amads/pitch/key/keysomdata.py
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
def project_and_animate(
    self,
    input_list: List[Tuple[float]],
    has_legend: bool = True,
    font_size: Union[float, str] = 10.0,
    color_map: Optional[Union[str, mcolors.LinearSegmentedColormap]] = None,
    show: bool = True,
) -> Tuple[List[np.array], FuncAnimation]:
    """
    Animate a collection of pitch-class distributions.

    Parameters
    ----------
    input_list: List[Tuple[float]]
        a list of pitch-class distributions, in which case the visualization
        is an animation of the sequence of projections of the pitch-class
        distributions onto the trained SOM.
    has_legend: bool
        Whether or not the plot should include a color legend
    font_size: Union[float, str]
        Font size, either:

        1. Font size of the labels (in points) or a string option from
            matplotlib
        2. None for the default font size provided by matplotlib
    color_map: Optional[Union[str, mcolors.LinearSegmentedColormap]]
        Color map, either:

         1. a color map provided by the matplotlib package
         2. a custom linear segmented colormap
             (see matplotlib.LinearSegmentedColormap for more details)
         3. None for the default color scheme provided by matplotlib
    show: bool
        Whether or not we suspend execution and display the plot before
        returning from this function

    Returns
    -------
    Tuple[List[np.array], ArtistAnimation]

        1. A list of 2-D numpy arrays that contain the sequence of
            projections from the list of input data onto the
            self-organizing map
        2. The artist animation object of these data
    """
    # visualize
    projection_list = [
        self.project_input_onto_SOM(np.array(input)) for input in input_list
    ]
    print(projection_list)
    if not projection_list:
        print("Warning! No distributions provided to animate!")
        return

    fig, ax = plt.subplots()

    cax = ax.imshow(
        projection_list[0],
        aspect="auto",
        origin="lower",
        interpolation="nearest",
        cmap=color_map,
    )
    cax.set_clim(self.vmin, self.vmax)
    # key labels in the plot
    for (i, j), label in zip(self.label_coord_list, KeyProfileSOM._labels):
        ax.text(
            j,
            i,
            label,
            ha="center",
            va="center",
            color="w",
            fontsize=font_size,
        )
    if has_legend:
        fig.colorbar(cax, ax=ax, label="Score Expectation", ticks=None)

    def frame_func(frame_idx):
        idx = frame_idx % len(projection_list)
        cax.set_data(projection_list[idx])

    ani = FuncAnimation(
        fig,
        frame_func,
        frames=len(input_list),
        interval=500,
        repeat=True,
        repeat_delay=2000,
    )

    if show:
        plt.show()

    return projection_list, ani

pretrained_weights_script

pretrained_weights_script() -> KeyProfileSOM

Simple script that generates a SOM from a hand-crafted initial SOM.

This gives us a SOM with key labels in a determinstic grid adjacent to the axes of the grid.

Returns:

Source code in amads/pitch/key/keysomdata.py
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
def pretrained_weights_script() -> KeyProfileSOM:
    """
    Simple script that generates a SOM from a hand-crafted initial SOM.

    This gives us a SOM with key labels in a determinstic grid adjacent to the
    axes of the grid.

    Returns
    -------
    KeyProfileSOM
        Object with training weights
    """

    # for the pretrained version, hand-craft initialization value for the
    # SOM so that we get the rotation and orientation desired for the resulting
    # trained weights
    obj = KeyProfileSOM()
    if obj.SOM_output_dims != KeyProfileSOM._default_output_dimensions:
        raise RuntimeError("invalid output dimensions for default SOM")

    obj.train_SOM(weights_initialization=handcrafted_SOM_init)

    return obj

zero_SOM_init

zero_SOM_init(shape: Tuple[int, int, int]) -> array

Constructs all-zero self-organizing map weights.

Source code in amads/pitch/key/keysomdata.py
48
49
50
51
52
53
def zero_SOM_init(shape: Tuple[int, int, int]) -> np.array:
    """
    Constructs all-zero self-organizing map weights.
    """
    # zero SOM init...
    return np.zeros(shape)

random_SOM_init

random_SOM_init(shape: Tuple[int, int, int]) -> array

Constructs random self-organizing map weights.

Initial values are between 0 and 0.5 inclusive.

Source code in amads/pitch/key/keysomdata.py
56
57
58
59
60
61
62
def random_SOM_init(shape: Tuple[int, int, int]) -> np.array:
    """
    Constructs random self-organizing map weights.

    Initial values are between 0 and 0.5 inclusive.
    """
    return np.random.rand(*shape) / 2

handcrafted_SOM_init

handcrafted_SOM_init(shape: Tuple[int, int, int]) -> array

Bespoke initialization for the pretrained weights.

These initial weights are intended to bias the resulting SOM to a nicely symmetrical map with well-placed keys.

Source code in amads/pitch/key/keysomdata.py
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
def handcrafted_SOM_init(shape: Tuple[int, int, int]) -> np.array:
    """
    Bespoke initialization for the pretrained weights.

    These initial weights are intended to bias the resulting SOM
    to a nicely symmetrical map with well-placed keys.
    """
    if shape != (
        *KeyProfileSOM._default_output_dimensions,
        KeyProfileSOM._input_length,
    ):
        raise ValueError(f"invalid shape {shape} for handcrafted SOM")

    kkprof = prof.krumhansl_kessler
    list_of_majors = kkprof["major"].normalize().as_canonical_matrix()
    list_of_minors = kkprof["minor"].normalize().as_canonical_matrix()

    max_row, max_col, _ = shape

    init_weights = np.zeros(shape)

    # per "cell" arrangement, where each cell contains a major key label and its
    # corresponding minor key label horizontally adjacent to it

    # arithmetic for a 4x3 grid of cells in a 24x36 SOM
    row_multiplier = 6
    max_row_cells = max_row // row_multiplier
    row_offset = 3
    # twice to accomodate a cell containing 12
    col_multiplier = 6 * 2
    max_col_cells = max_col // col_multiplier
    col_major_offset = 3
    col_minor_offset = col_major_offset + 6

    key_idx = 0
    # the end result should be a rectangular grid in the labels
    for row_cell_idx in range(max_row_cells):
        for col_cell_idx in range(max_col_cells):
            # imprint major key
            major_col_idx = col_cell_idx * col_multiplier + col_major_offset
            major_row_idx = row_cell_idx * row_multiplier + row_offset
            init_weights[major_row_idx, major_col_idx] = list_of_majors[key_idx]

            # imprint minor key
            minor_col_idx = col_cell_idx * col_multiplier + col_minor_offset
            minor_row_idx = row_cell_idx * row_multiplier + row_offset
            init_weights[minor_row_idx, minor_col_idx] = list_of_minors[key_idx]

            # increment key_idx
            key_idx += 1

    assert key_idx == 12

    return init_weights

keysom_inverse_decay

keysom_inverse_decay(idx: int) -> float

Computes inverse decay global learning rate for a given iteration.

Inverse to the current learning iteration...

Source code in amads/pitch/key/keysomdata.py
121
122
123
124
125
126
127
128
129
130
131
def keysom_inverse_decay(idx: int) -> float:
    """
    Computes inverse decay global learning rate for a given iteration.

    Inverse to the current learning iteration...
    """
    assert idx >= 0
    if idx == 0:
        return 1
    else:
        return 1 / (2 * idx)

keysom_stepped_inverse_decay

keysom_stepped_inverse_decay(idx: int) -> float

Compute stepped decay global learning rate for a given iteration.

Inverse to a stepped multiplier of the current learning iteration...

In this case it's stepped to allow all inputs to deterministically pass during training (for 12 major and 12 minor key profile entries specifically)

Source code in amads/pitch/key/keysomdata.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
def keysom_stepped_inverse_decay(idx: int) -> float:
    """
    Compute stepped decay global learning rate for a given iteration.

    Inverse to a stepped multiplier of the current learning iteration...

    In this case it's stepped to allow all inputs to deterministically
    pass during training (for 12 major and 12 minor key profile entries
    specifically)
    """
    step = idx // (12 * 2)
    if step == 0:
        return 1
    else:
        return 1 / (2 * step)

keysom_stepped_log_inverse_decay

keysom_stepped_log_inverse_decay(idx: int) -> float

Compute stepped log inverse decay global learning rate for a given iteration.

Log inverse to a stepped multiplier of the current learning iteration...

In this case it's stepped to allow all inputs to deterministically pass during training (for 12 major and 12 minor key profile entries specifically)

So why does inverse log work well? I like to think of it as the summation of traversing all nodes of a subtree of an imaginary sparse information tree. Where each layer of the information tree provides inverse decaying returns to the whole tree. This justification is very stretched though. Namely, each additional data point fed provides an opportunity to add another "symbol" to the intrinsic learned "alphabet" of the internal representation of the SOM.

Source code in amads/pitch/key/keysomdata.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
def keysom_stepped_log_inverse_decay(idx: int) -> float:
    """
    Compute stepped log inverse decay global learning rate for a given iteration.

    Log inverse to a stepped multiplier of the current learning iteration...

    In this case it's stepped to allow all inputs to deterministically
    pass during training (for 12 major and 12 minor key profile entries
    specifically)

    So why does inverse log work well? I like to think of it as the summation
    of traversing all nodes of a subtree of an imaginary sparse information tree.
    Where each layer of the information tree provides inverse decaying
    returns to the whole tree.
    This justification is very stretched though.
    Namely, each additional data point fed provides an opportunity to add
    another "symbol" to the intrinsic learned "alphabet" of the internal
    representation of the SOM.
    """
    step = idx // (12 * 2)
    if step == 0:
        return 1
    else:
        return 1 / math.log2(step + 2)

keysom_centroid_euclidean

keysom_centroid_euclidean(
    coord: Tuple[int],
    best_match: Tuple[int],
    shape: Tuple[int],
    idx: int,
) -> float

Neighborhood propagation update function.

Exponential decay based off of Eucliean distance on a 2-D plane assuming the SOM output layer is a 2-D flat plane

Source code in amads/pitch/key/keysomdata.py
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
def keysom_centroid_euclidean(
    coord: Tuple[int], best_match: Tuple[int], shape: Tuple[int], idx: int
) -> float:
    """
    Neighborhood propagation update function.

    Exponential decay based off of Eucliean distance on a 2-D plane
    assuming the SOM output layer is a 2-D flat plane
    """
    distance = euclidean_distance(coord, best_match, False)

    # 0.95 is purely empirical. Honestly another value might be better
    if distance == 0:
        return 1
    else:
        return (0.95) ** distance

keysom_toroid_euclidean

keysom_toroid_euclidean(
    coord: Tuple[int],
    best_match: Tuple[int],
    shape: Tuple[int],
    idx: int,
) -> float

Neighborhood propagation update function.

Exponential decay based off of Eucliean distance on a toroid assuming the SOM output layer is a projection of a toroid onto a 2-D flat plane

Source code in amads/pitch/key/keysomdata.py
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
def keysom_toroid_euclidean(
    coord: Tuple[int], best_match: Tuple[int], shape: Tuple[int], idx: int
) -> float:
    """
    Neighborhood propagation update function.

    Exponential decay based off of Eucliean distance on a toroid
    assuming the SOM output layer is a projection of a toroid onto a
    2-D flat plane
    """
    num_rows, num_cols, input_length = shape
    # these are easy to follow since i, j are convention
    # and so is i0, j0
    i, j = coord
    i0, j0 = best_match
    diff_row = abs(i - i0)
    diff_col = abs(j - j0)
    toroid_diff_row = min(diff_row, num_rows - diff_row)
    toroid_diff_col = min(diff_col, num_cols - diff_col)
    distance = math.sqrt(toroid_diff_row**2 + toroid_diff_col**2)
    if distance == 0:
        return 1
    else:
        return (0.9) ** distance

keysom_toroid_clamped

keysom_toroid_clamped(
    coord: Tuple[int],
    best_match: Tuple[int],
    shape: Tuple[int],
    idx: int,
) -> float

Neighborhood propagation update function.

Same behavior as keysom_toroid_euclidean (see for more details), except distances past a certain radius is clamped to 0.0001.

Source code in amads/pitch/key/keysomdata.py
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
def keysom_toroid_clamped(
    coord: Tuple[int], best_match: Tuple[int], shape: Tuple[int], idx: int
) -> float:
    """
    Neighborhood propagation update function.

    Same behavior as keysom_toroid_euclidean (see for more details),
    except distances past a certain radius is clamped to 0.0001.
    """
    num_rows, num_cols, input_length = shape
    # these are easy to follow since i, j are convention
    # and so is i0, j0
    i, j = coord
    i0, j0 = best_match
    diff_row = abs(i - i0)
    diff_col = abs(j - j0)
    toroid_diff_row = min(diff_row, num_rows - diff_row)
    toroid_diff_col = min(diff_col, num_cols - diff_col)
    distance = math.sqrt(toroid_diff_row**2 + toroid_diff_col**2)

    # same logic applies to clamp radius as the global learning decay rate.
    # Namely, each additional data point fed provides an opportunity to add
    # another "symbol" to the intrinsic learned "alphabet" of the internal
    # representation of the SOM.
    # diminishing returns...
    radius = 36.0 * (1 / math.log2(idx // 24 + 2))

    if distance > radius:
        return 0.0001
    elif distance == 0:
        return 1.0
    else:
        return (0.9) ** distance