Skip to content

Variability

variability

Time variability.

This module provides various functions for calculating rhythmic variability in music, including the normalized pairwise variability index (nPVI) and variations thereof.

Author: Huw Cheston (2025)

References
  • Daniele, J. R., & Patel, A. D. (2013). An Empirical Study of Historical Patterns in Musical Rhythm. Music Perception, 31(1), 10-18. https://doi.org/10.1525/mp.2013.31.1.10

  • Condit-Schultz, N. (2019). Deconstructing the nPVI: A Methodological Critique of the Normalized Pairwise Variability Index as Applied to Music. Music Perception, 36(3), 300–313. https://doi.org/10.1525/mp.2019.36.3.300

  • VanHandel, L., & Song, T. (2010). The role of meter in compositional style in 19th-century French and German art song. Journal of New Music Research, 39, 1–11. https://doi.org/10.1080/09298211003642498


normalized_pairwise_variability_index

normalized_pairwise_variability_index(
    durations: Iterable[float],
) -> float

Calculates the normalised pairwise variability index (nPVI).

The nPVI is a measure of variability between successive elements in a sequence. The equation is:

$\text{nPVI} = \frac{100}{m-1} \times \sum\limits_{k=1}^{m-1} \left| \frac{d_k - d_{k+1}}{\frac{d_k + d_{k+1}}{2}} \right|$

where $m$ is the number of intervals, and $d_k$ is the duration of the $k^{th}$ interval.

A completely regular stream of equal durations returns 0 variability. High difference between successive items returns a high nPVI value irrespective of other (e.g., metrical) considerations.

Parameters:

  • durations (Iterable[float]) –

    the durations to analyse

Returns:

  • float

    the extracted nPVI value.

Examples:

A completely regular stream of equal durations returns 0 variability.

>>> normalized_pairwise_variability_index([1., 1., 1., 1.])
0.

This example is from Daniele & Patel (2013, Appendix).

>>> durs = [1., 1/2, 1/2, 1., 1/2, 1/2, 1/3, 1/3, 1/3, 2., 1/3, 1/3, 1/3, 1/3, 1/3, 1/3, 3/2, 1., 1/2]
>>> x = normalized_pairwise_variability_index(durations=durs)
>>> round(x, 1)
42.2

These next examples are from Condit-Schultz's 2019 critique (Figure 2).

>>> normalized_pairwise_variability_index([0.25, 0.25, 0.25, 0.25, 1., 0.25, 0.25, 0.25, 0.25, 1.])
40.
>>> normalized_pairwise_variability_index([0.25, 0.25, 0.5, 1., 0.25, 0.25, 0.5, 1.])
55.2
>>> normalized_pairwise_variability_index([0.5, 0.25, 0.25, 1., 0.5, 0.25, 0.25, 1.])
62.8
>>> normalized_pairwise_variability_index([2, 1, 2, 1])
66.66
>>> normalized_pairwise_variability_index([0.5, 1, 2, 1, 0.5])
66.66
>>> normalized_pairwise_variability_index([2, 1, 0.5, 1, 0.5, 0.25])
66.66
Source code in amads/time/variability.py
 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
def normalized_pairwise_variability_index(
    durations: Iterable[float],
) -> float:
    r"""
    Calculates the normalised pairwise variability index (nPVI).

    The nPVI is a measure of variability between successive elements
    in a sequence. The equation is:

    $\text{nPVI} = \frac{100}{m-1} \times \sum\limits_{k=1}^{m-1}
    \left| \frac{d_k - d_{k+1}}{\frac{d_k + d_{k+1}}{2}} \right|$

    where $m$ is the number of intervals, and $d_k$ is the duration
    of the $k^{th}$ interval.

    A completely regular stream of equal durations returns 0 variability.
    High difference between successive items returns a high nPVI value
    irrespective of other (e.g., metrical) considerations.

    Parameters
    ----------
    durations : Iterable[float]
        the durations to analyse

    Returns
    -------
    float
        the extracted nPVI value.

    Examples
    -------

    A completely regular stream of equal durations returns 0 variability.

    >>> normalized_pairwise_variability_index([1., 1., 1., 1.])
    0.

    This example is from Daniele & Patel (2013, Appendix).

    >>> durs = [1., 1/2, 1/2, 1., 1/2, 1/2, 1/3, 1/3, 1/3, 2., 1/3, 1/3, 1/3, 1/3, 1/3, 1/3, 3/2, 1., 1/2]
    >>> x = normalized_pairwise_variability_index(durations=durs)
    >>> round(x, 1)
    42.2

    These next examples are from Condit-Schultz's 2019 critique (Figure 2).

    >>> normalized_pairwise_variability_index([0.25, 0.25, 0.25, 0.25, 1., 0.25, 0.25, 0.25, 0.25, 1.])
    40.

    >>> normalized_pairwise_variability_index([0.25, 0.25, 0.5, 1., 0.25, 0.25, 0.5, 1.])
    55.2

    >>> normalized_pairwise_variability_index([0.5, 0.25, 0.25, 1., 0.5, 0.25, 0.25, 1.])
    62.8

    >>> normalized_pairwise_variability_index([2, 1, 2, 1])
    66.66

    >>> normalized_pairwise_variability_index([0.5, 1, 2, 1, 0.5])
    66.66

    >>> normalized_pairwise_variability_index([2, 1, 0.5, 1, 0.5, 0.25])
    66.66
    """

    _validate_inputs(durations)
    numerator = (
        sum(
            [
                abs((k - k1) / ((k + k1) / 2))
                for (k, k1) in zip(durations, durations[1:])
            ]
        )
        * 100
    )
    denominator = len(durations) - 1
    return numerator / denominator

normalized_pairwise_calculation

normalized_pairwise_calculation(
    durations: Iterable[float],
) -> list[float]

Calculates the normalized pairwise calculation (nPC) for a list of durations, as defined by Condit-Schultz (2019).

The nPVI is equivalent to the arithmetic mean of a set of nPC values. The equation for nPC can be written as:

$\text{nPC} = 200 * \biggl{|} \frac{\text{antecedent IOI} - \text{consequent IOI}} {\text{antecedent IOI} + \text{consequent IOI}} \biggr{|}$

Parameters:

  • durations (Iterable[float]) –

    the durations to analyse

Returns:

  • Iterable[float]

    the extracted nPC value(s) for every pair of antecedent-consequent durations

Examples:

From Condit-Schultz (2019) figure 3.

>>> normalized_pairwise_calculation([1, 15])
[175.]
>>> normalized_pairwise_calculation([1, 7])
[150.]
>>> normalized_pairwise_calculation([1, 5])
[133.33]
>>> normalized_pairwise_calculation([1, 4])
[120.]
>>> normalized_pairwise_calculation([1, 3])
[100.]
>>> normalized_pairwise_calculation([1, 2])
[66.67]
>>> normalized_pairwise_calculation([2, 3])
[40.]
>>> normalized_pairwise_calculation([1, 1])
[0.]
Source code in amads/time/variability.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
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
def normalized_pairwise_calculation(durations: Iterable[float]) -> list[float]:
    r"""
    Calculates the normalized pairwise calculation (nPC) for a list of
    durations, as defined by Condit-Schultz (2019).

    The nPVI is equivalent to the arithmetic mean of a set of nPC values.
    The equation for nPC can be written as:

    $\text{nPC} = 200 * \biggl{|} \frac{\text{antecedent IOI} -
                                        \text{consequent IOI}}
        {\text{antecedent IOI} + \text{consequent IOI}} \biggr{|}$

    Parameters
    ----------
    durations : Iterable[float]
        the durations to analyse

    Returns
    -------
    Iterable[float]
        the extracted nPC value(s) for every pair of
        antecedent-consequent durations

    Examples
    --------

    From Condit-Schultz (2019) figure 3.

    >>> normalized_pairwise_calculation([1, 15])
    [175.]

    >>> normalized_pairwise_calculation([1, 7])
    [150.]

    >>> normalized_pairwise_calculation([1, 5])
    [133.33]

    >>> normalized_pairwise_calculation([1, 4])
    [120.]

    >>> normalized_pairwise_calculation([1, 3])
    [100.]

    >>> normalized_pairwise_calculation([1, 2])
    [66.67]

    >>> normalized_pairwise_calculation([2, 3])
    [40.]

    >>> normalized_pairwise_calculation([1, 1])
    [0.]

    """

    _validate_inputs(durations)
    return [
        _normalized_pairwise_calculation(a_ioi, c_ioi)
        for a_ioi, c_ioi in zip(durations, durations[1:])
    ]

isochrony_proportion

isochrony_proportion(durations: Iterable[float]) -> float

Calculates the isochrony proportion (IsoP) for a list of durations, as defined in Condit-Schultz (2019): "by iterating over every pair of successive IOIs in a rhythm, counting the pairs that are identical, and dividing this count by the total number of pairs (one less than the total number of IOIs)."

Condit-Schultz 2019 demonstrates that the IsoP accounts for a large degree of the variance in the nPVI.

Parameters:

  • durations (Iterable[float]) –

Returns:

  • float ( the extracted IsoP value ) –

Examples:

>>> isochrony_proportion([1, 1, 2, 2, 1, 0.5])
0.4
Source code in amads/time/variability.py
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
def isochrony_proportion(
    durations: Iterable[float],
) -> float:
    r"""
    Calculates the isochrony proportion (IsoP) for a list of durations, as defined in Condit-Schultz (2019):
    "by iterating over every pair of successive IOIs in a rhythm,
    counting the pairs that are identical,
    and dividing this count by the total number of pairs (one less than the total number of IOIs)."

    Condit-Schultz 2019 demonstrates that the IsoP accounts for a large degree of the variance in the nPVI.

    Parameters
    ----------
    durations (Iterable[float]): the durations to analyse

    Returns
    -------
    float: the extracted IsoP value

    Examples
    --------
    >>> isochrony_proportion([1, 1, 2, 2, 1, 0.5])
    0.4

    """

    _validate_inputs(durations)
    isochronous = 0
    # Iterate over every pair of successive IOIs in a rhythm
    for a1, a2 in zip(durations, durations[1:]):
        # If both IOIs are the same
        if math.isclose(a1, a2, abs_tol=TINY):
            # Increase the counter by one
            isochronous += 1
    # Divide the counter by the total number of pairs (one less than the total number of IOIs)
    denominator = len(durations) - 1
    return isochronous / denominator

pairwise_anisochronous_contrast_index

pairwise_anisochronous_contrast_index(
    durations: Iterable[float],
) -> float

Calculates the pairwise anisochronous contrast index (pACI) for a list of durations.

Defined in Condit-Schultz (2019), the pACI is equivalent to the nPVI, except that it "factors out" isochronous pairs of IOIs. This means that it is sensitive to changes in the frequencies of IOIs pairs such as 2:1, 3:1, without being "overwhelmed by isochronous pairs" (1:1).

Parameters:

  • durations (Iterable[float]) –

Returns:

  • float ( the extracted pACI value ) –
Source code in amads/time/variability.py
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
274
def pairwise_anisochronous_contrast_index(
    durations: Iterable[float],
) -> float:
    r"""
    Calculates the pairwise anisochronous contrast index (pACI) for a list of durations.

    Defined in Condit-Schultz (2019), the pACI is equivalent to the nPVI,
    except that it "factors out" isochronous pairs of IOIs.
    This means that it is sensitive to changes in the frequencies of IOIs pairs such as 2:1, 3:1, without
    being "overwhelmed by isochronous pairs" (1:1).

    Parameters
    ----------
    durations (Iterable[float]): the durations to analyse

    Returns
    -------
    float: the extracted pACI value

    """

    _validate_inputs(durations)
    all_npcs = []
    # Iterate over successive IOIs
    for a1, a2 in zip(durations, durations[1:]):
        # If the two IOIs are NOT isochronous
        if not math.isclose(a1, a2, abs_tol=TINY):
            # Calculate the nPC for this pair of durations
            npc = _normalized_pairwise_calculation(a1, a2)
            all_npcs.append(npc)
    # Raise errors as required
    if len(all_npcs) == 0:
        raise ValueError(
            "No non-isochronous pairs were found, cannot calculate pACI."
        )
    # Return the average
    return sum(all_npcs) / len(all_npcs)

phrase_normalized_pairwise_variability_index

phrase_normalized_pairwise_variability_index(
    durations: Iterable[float], phrase_boundaries: Iterable[float]
) -> float

Calculates the phrase-normalized pairwise variability index (pnPVI), as defined by VanHandel & Song (2010).

The pnNPVI calculates the nPVI, ignoring cases where pairs of IOIs straddle a phrase boundary. To do so, we also need a list of times for where those phrase boundaries fall.

Parameters:

  • durations (Iterable[float]) –

    phrase_boundaries (Iterable[float]): the phrase boundaries to analyse

Returns:

  • float ( the extracted pnNPVI value ) –
Source code in amads/time/variability.py
277
278
279
280
281
282
283
284
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
def phrase_normalized_pairwise_variability_index(
    durations: Iterable[float],
    phrase_boundaries: Iterable[float],
) -> float:
    r"""
    Calculates the phrase-normalized pairwise variability index (pnPVI), as defined by VanHandel & Song (2010).

    The pnNPVI calculates the nPVI, ignoring cases where pairs of IOIs straddle a phrase boundary.
    To do so, we also need a list of times for where those phrase boundaries fall.

    Parameters
    ----------
    durations (Iterable[float]): the durations to analyse
        phrase_boundaries (Iterable[float]): the phrase boundaries to analyse

    Returns
    -------
    float: the extracted pnNPVI value

    """

    # Validate all inputs
    _validate_inputs(durations)
    _validate_inputs(
        phrase_boundaries, _kind="phrase boundaries", _min=1
    )  # need at least one phrase boundary
    # We use the "counter" to keep track of where we are in the phrase
    counter = 0
    all_npcs = []
    # Iterate over pairs of IOIs
    for a1, a2 in zip(durations, durations[1:]):
        # This is where we "end" the current boundary: add both IOIs to the counter
        end_pos = counter + a1 + a2
        # If we're not straddling any of the phrase boundaries
        if not any([counter <= pb <= end_pos for pb in phrase_boundaries]):
            # Calculate the nPC
            npc = _normalized_pairwise_calculation(a1, a2)
            all_npcs.append(npc)
        # Add the first IOI to the counter to move forwards in time for the next pair
        counter += a1
    # Raise errors as required
    if len(all_npcs) == 0:
        raise ValueError(
            "All pairs of IOIs cross phrase boundaries, cannot calculate pnNPVI."
        )
    # Return the average of all nPC values
    return sum(all_npcs) / len(all_npcs)