User:Arseniiv/Fitting: Difference between revisions
m oh, mod wasn’t working at all, why would I write this?.. |
m →Code: a link to a gist |
||
(3 intermediate revisions by the same user not shown) | |||
Line 20: | Line 20: | ||
Then, <math>E_k</math> is still our true fit of <math>I_k</math> in case of our average being the last case, arithmetic mean, RMS and several other kinds of averagings because we’ll still have <math>-\frac S2 \le \Delta_k - d \le +\frac S2</math>. | Then, <math>E_k</math> is still our true fit of <math>I_k</math> in case of our average being the last case, arithmetic mean, RMS and several other kinds of averagings because we’ll still have <math>-\frac S2 \le \Delta_k - d \le +\frac S2</math>. | ||
=== To a periodic scale with specific step pattern === | |||
If we have a pattern of steps in mind, say sLMLsLMsML, we can try fitting a scale to a scale of this form by writing an error term for each note in the period of the new scale. These terms will be integer linear combinations of our chosen step sizes <math>s, M, L</math>, an overal scale shift <math>d</math> (see [[#To edX]]) and numbers (cent values of intervals). | |||
Having all those errors in symbolic form, it’s particularly easy to minimize RMS error constrained by all steps of the scale constituting exactly the period of the old scale, using Lagrange multiplier method. We end up with a linear system comprising of equations in step sizes, <math>d</math> and a single Lagrange multiplier related to the periodicity constraint, which is then solved to get the optimal step sizes. All these computations can be done using <code>sympy</code> Python package. In the code below I also use <code>more_itertools</code> to make my life a tad easier. | |||
==== Code ==== | |||
''NB. This code may later be lagging behind [https://gist.github.com/arseniiv/ab128927adc3955867c1cc1bfe6b4cd7 the version at Gist]. The version there is a self-contained module with an example like one in this article.'' | |||
Should work in Python 3.12 and several previous versions. Install fresh <code>more_itertools</code> and <code>sympy</code>. | |||
<pre> | |||
from typing import Sequence | |||
from collections import defaultdict | |||
from dataclasses import dataclass | |||
from fractions import Fraction | |||
from math import log2, isclose, sqrt | |||
from more_itertools import difference | |||
import sympy as sp | |||
def _cents(ratio: float | str) -> float: | |||
if isinstance(ratio, str): | |||
ratio = float(Fraction(ratio)) | |||
return 1200. * log2(ratio) | |||
def scale_from_str(intervals: str) -> tuple[float, ...]: | |||
"""Make a scale of pitches in cents from intervals delimited by spaces.""" | |||
return tuple(map(_cents, intervals.split())) | |||
def match_pattern(scale: Sequence[float], pattern: str) -> dict[str, float] | None: | |||
"""Check if the scale matches a given pattern of steps like 'LMsMs'.""" | |||
if len(scale) != len(pattern): | |||
raise ValueError('scale and pattern should have the same length') | |||
step_sizes = defaultdict(set) | |||
for letter, size in zip(pattern, difference(scale), strict=True): | |||
step_sizes[letter].add(size) | |||
res = dict() | |||
for letter, sizes in step_sizes.items(): | |||
mean_size = sum(sizes) / len(sizes) | |||
if any(not isclose(size, mean_size) for size in sizes): | |||
return None | |||
res[letter] = mean_size | |||
return res | |||
@dataclass | |||
class ScaleFit: | |||
"""Results of a scale fit. RMS error is what’s minimized.""" | |||
pattern: str | |||
pitches: list[float] | |||
step_sizes: dict[str, float] | |||
errors: list[float] | |||
rms_error: float | |||
avg_error: float | |||
max_error: float | |||
def fit_scale(scale: Sequence[float], pattern: str) -> ScaleFit: | |||
"""Fit this scale to a given pattern of steps like 'LMsMs'.""" | |||
SHIFT, LAGRANGE = 'SHIFT', 'LAMBDA' | |||
assert set(pattern).isdisjoint([SHIFT, LAGRANGE]) | |||
vars_ = {letter: sp.Symbol(letter) | |||
for letter in set(pattern) | {SHIFT, LAGRANGE}} | |||
note_errors = [vars_[SHIFT]] | |||
sym_pitches = [sp.sympify(0)] | |||
for letter, pitch in zip(pattern, scale): | |||
last_pitch = sym_pitches[-1] + vars_[letter] | |||
sym_pitches.append(last_pitch) | |||
note_errors.append(vars_[SHIFT] + last_pitch - pitch) | |||
periodic_constraint = note_errors[-1] - note_errors[0] | |||
note_errors.pop() | |||
sym_pitches.pop(0) | |||
assert len(note_errors) == len(scale) | |||
error_sqr_sum = sum(error ** 2 for error in note_errors) | |||
cost = error_sqr_sum - vars_[LAGRANGE] * periodic_constraint | |||
equations = tuple(cost.diff(x) for x in vars_) | |||
steps_sol = sp.solve(equations, vars_) | |||
actual_errors = [float(error.subs(steps_sol)) for error in note_errors] | |||
rms_error = sqrt(sum(error ** 2 for error in actual_errors)) / len(actual_errors) | |||
avg_error = sum(abs(error) for error in actual_errors) / len(actual_errors) | |||
max_error = max(abs(error) for error in actual_errors) | |||
new_scale = [float(pitch.subs(steps_sol)) for pitch in sym_pitches] | |||
assert match_pattern(new_scale, pattern) | |||
return ScaleFit( | |||
pattern=pattern, | |||
pitches=new_scale, | |||
step_sizes={letter: steps_sol[vars_[letter]] for letter in set(pattern)}, | |||
errors=actual_errors, | |||
rms_error=rms_error, | |||
avg_error=avg_error, | |||
max_error=max_error) | |||
</pre> | |||
This can be used like so: | |||
<pre> | |||
from pprint import pprint # more readable output | |||
initial_scale = scale_from_str('33/32 9/8 11/9 4/3 11/8 3/2 44/27 27/16 11/6 2/1') | |||
initial_pattern = 'sLMLsLMzML' | |||
assert match_pattern(initial_scale, initial_pattern) # just a sanity check | |||
# equalize s and z small steps in the scale: | |||
fit = fit_scale(initial_scale, initial_pattern.replace('z', 's')) | |||
pprint(fit) | |||
</pre> | |||
which outputs: | |||
<pre> | |||
ScaleFit(pattern='sLMLsLMsML', # the new pattern we fit to | |||
pitches=[56.16718090477973, # scale data | |||
204.97122221148004, | |||
350.39865289776657, | |||
499.2026942044669, | |||
555.3698751092467, | |||
704.1739164159469, | |||
849.6013471022335, | |||
905.7685280070132, | |||
1051.1959586932996, | |||
1200.0], | |||
step_sizes={'L': 148.804041306700, # each step size specifically | |||
'M': 145.427430686286, | |||
's': 56.1671809047797}, | |||
errors=[-2.0259663722433108, # signed errors for each note compared to the initial scale | |||
0.8682713023922943, | |||
-0.9647458915380938, | |||
0.9647458915412628, | |||
-0.8682713023889406, | |||
2.0259663722465575, | |||
0.1929491783161552, | |||
2.1224409613957675, | |||
-2.122440961392499, | |||
-0.19294917831297198], | |||
rms_error=0.4545581250243142, # RMS error (optimized for) | |||
avg_error=1.2348747411767853, # arithmetic mean (absolute) error | |||
max_error=2.1224409613957675) # maximum (absolute) error | |||
</pre> | |||
The scale can be printed to be used in an .scl file or similar contexts with this: | |||
<pre> | |||
print('\n'.join(map(str, fit.pitches))) | |||
</pre> | |||
== Notes == | == Notes == | ||
<references /> | <references /> |