dtwrb 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a69f04b76a452056c2749e7f695a8d9adcb3f1469e4f672ff2f9a5f95c7ab116
4
+ data.tar.gz: 0bc45f0c9b9947b6be08ec1c26fb318d6e6a18c47ef2c789a60d869c5889a709
5
+ SHA512:
6
+ metadata.gz: 2d93d6b736d7655571db2a0af148d7f35e5b74d26bb79d3313542c3ef2d31f15e01b522b961d32500e259bb636cefa3cea3e7cc0c43b1b88686f955460554b5f
7
+ data.tar.gz: 3bd0dcd7d9c90ee8280f031f6621b5e8d220b79af807e766e2077c8bb94d1b64fc36a115a92d8d9490c59eda14e152b0709ac0568ef63aed2436d2c831a01105
data/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 — 2026-08-01
4
+
5
+ Initial public release.
6
+
7
+ - DTW alignment with the Sakoe–Chiba band constraint (`DTW::Aligner`, `DTW::Bands`)
8
+ - Local distances: Euclidean, Manhattan, Chebyshev, cosine (`DTW::Metrics`)
9
+ - DTW Barycenter Averaging with median or mean aggregation (`DTW::BarycenterAveraging`)
10
+ - Approximate medoid selection over a uniform subsample (`DTW::Medoid`)
11
+ - Linear resampling of frame sequences (`DTW::Resampler`)
12
+ - Median, mean, MAD, and Bessel-corrected standard deviation (`DTW::Statistics`)
13
+ - Scalar and multivariate sequences accepted interchangeably (`DTW::Sequence`, `DTW::Sample`)
14
+ - RBS signatures under `sig/`
data/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
2
+ Version 2, December 2004
3
+
4
+ Copyright (C) 2026 Den Patin <hi@dpat.in>
5
+
6
+ Everyone is permitted to copy and distribute verbatim or modified
7
+ copies of this license document, and changing it is allowed as long
8
+ as the name is changed.
9
+
10
+ DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
11
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
12
+
13
+ 0. You just DO WHAT THE FUCK YOU WANT TO.
data/README.md ADDED
@@ -0,0 +1,212 @@
1
+ # dtwrb
2
+
3
+ Dynamic Time Warping (DTW), DTW Barycenter Averaging (DBA), and robust dispersion estimation for
4
+ numeric sequences. Pure Ruby, no runtime dependencies.
5
+
6
+ Extracted from [taiwancards](https://github.com/taiwancards/taiwancards), where it drives acoustic
7
+ pronunciation scoring.
8
+
9
+ ## The problem
10
+
11
+ Point-to-point comparison — Euclidean distance between samples at equal indices — assumes that two
12
+ sequences have the same length and the same phase. Repeated observations of one process violate
13
+ both assumptions: two utterances of the same syllable, two executions of the same gesture, and two
14
+ runs of the same machine cycle differ in duration and in local timing.
15
+
16
+ DTW drops the assumption. It computes the minimum-cost monotone, continuous alignment between two
17
+ sequences, letting either one stretch or compress locally. DBA lifts the idea from comparison to
18
+ estimation: given many variable-length observations of one process, it returns a single prototype
19
+ sequence in the DTW sense.
20
+
21
+ ## Install
22
+
23
+ ```ruby
24
+ gem "dtwrb"
25
+ ```
26
+
27
+ ## Alignment
28
+
29
+ Accumulated cost follows the standard recurrence with the symmetric step pattern
30
+ `{(1,1), (1,0), (0,1)}`:
31
+
32
+ ```text
33
+ D(i, j) = d(a_i, b_j) + min{ D(i-1, j), D(i, j-1), D(i-1, j-1) }
34
+ ```
35
+
36
+ `d` is the local distance between two frames. `Alignment#cost` is `D(n, m)`. `Alignment#distance`
37
+ divides it by the number of path cells, which removes the bias that longer warping paths accumulate
38
+ more terms.
39
+
40
+ ```ruby
41
+ require "dtwrb"
42
+
43
+ a = [0.0, 1.0, 2.0, 3.0, 4.0]
44
+ b = [0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 4.0]
45
+
46
+ DTW.distance(a, b)
47
+ # => 0.0
48
+
49
+ DTW.align(a, b).path
50
+ # => [[0, 0], [0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [4, 6]]
51
+ ```
52
+
53
+ `b` is `a` with its first and last frame repeated. Euclidean comparison cannot even be formed here,
54
+ since the lengths differ; DTW reports exact identity and returns the path that proves it.
55
+
56
+ A sequence is either scalar (`[0.0, 1.0, ...]`) or multivariate (`[[0.0, 9.0], [1.0, 8.0], ...]`).
57
+ Scalar input is read as one-dimensional frames and yields scalar output.
58
+
59
+ ## Barycenter averaging
60
+
61
+ `DTW::BarycenterAveraging` implements DBA (Petitjean, Ketterlin & Gançarski, 2011):
62
+
63
+ 1. initialize the prototype from the medoid of the sample, resampled to the requested length;
64
+ 2. align every observation to the prototype;
65
+ 3. for each prototype frame and coordinate, aggregate the observation frames that the warping paths
66
+ associated with it;
67
+ 4. repeat until the mean absolute update falls below `tolerance`.
68
+
69
+ The default aggregator is the **median**, not the arithmetic mean of the original formulation. Its
70
+ breakdown point is 50%, so a minority of corrupt observations cannot displace the prototype.
71
+ Dispersion is reported per frame and coordinate as the median absolute deviation scaled by 1.4826,
72
+ a consistent estimator of σ under normality.
73
+
74
+ ```ruby
75
+ observations = [
76
+ [0.0, 1.0, 2.1, 3.0, 4.0],
77
+ [0.0, 0.0, 1.2, 2.0, 3.1, 4.0],
78
+ [0.0, 0.9, 2.0, 2.0, 2.9, 4.2],
79
+ [0.1, 1.1, 2.0, 3.2, 4.0]
80
+ ]
81
+
82
+ prototype = DTW.barycenter(observations, length: 5)
83
+
84
+ prototype.center # => [0.0, 1.05, 2.0, 3.05, 4.0]
85
+ prototype.dispersion # => [0.0, 0.148, 0.0, 0.148, 0.0]
86
+ prototype.count # => 4
87
+ prototype.length # => 5
88
+ ```
89
+
90
+ Length defaults to the rounded mean length of the sample. The classic mean-based formulation is one
91
+ argument away:
92
+
93
+ ```ruby
94
+ DTW.barycenter(observations, length: 5, aggregator: :mean).center
95
+ # => [0.02, 1.05, 2.02, 3.05, 4.05]
96
+ ```
97
+
98
+ ## Medoid
99
+
100
+ The medoid is the observation with the smallest total DTW distance to all others — the most central
101
+ real sequence, as opposed to a synthetic one. Exact selection costs O(k²) alignments, so candidates
102
+ are drawn as a uniform subsample of size `sample_size` (12 by default). Selection is deterministic;
103
+ no pseudo-random number generator is used anywhere in this library.
104
+
105
+ ```ruby
106
+ DTW.medoid(observations)
107
+ # => [0.0, 0.0, 1.2, 2.0, 3.1, 4.0]
108
+ ```
109
+
110
+ ## Local distances
111
+
112
+ Any object that responds to `#call(frame_a, frame_b) -> Float` is accepted.
113
+
114
+ | Name | Definition | Notes |
115
+ | --- | --- | --- |
116
+ | `:euclidean` | L² | default |
117
+ | `:manhattan` | L¹ | less sensitive to one deviant coordinate |
118
+ | `:chebyshev` | L∞ | worst-coordinate deviation |
119
+ | `:cosine` | 1 − cos θ, range [0, 2] | scale-invariant; compares direction only |
120
+
121
+ ```ruby
122
+ DTW.distance(a, b, metric: :manhattan)
123
+ DTW.distance(a, b, metric: ->(x, y) { (x.first - y.first).abs })
124
+ ```
125
+
126
+ ## Global constraint
127
+
128
+ The Sakoe–Chiba band (Sakoe & Chiba, 1978) restricts the alignment to cells with `|i − j| ≤ r`,
129
+ which bounds the admissible warping and reduces the cost matrix to a diagonal strip:
130
+
131
+ ```text
132
+ r = max( ⌈ratio · max(n, m)⌉, |n − m| + 1 )
133
+ ```
134
+
135
+ The second term guarantees that at least one path stays feasible, including for sequences of very
136
+ unequal length.
137
+
138
+ | Value | Meaning |
139
+ | --- | --- |
140
+ | `0.25` | default ratio |
141
+ | any `Numeric` | Sakoe–Chiba band with that ratio |
142
+ | `:unconstrained` | full cost matrix |
143
+ | custom | any object that responds to `#radius(n, m) -> Integer` |
144
+
145
+ Narrowing the band can only raise the accumulated cost, never lower it.
146
+
147
+ ## Statistics and resampling
148
+
149
+ ```ruby
150
+ DTW::Statistics.median([5.0, 1.0, 3.0]) # => 3.0
151
+ DTW::Statistics.median_absolute_deviation([10.0, 11.0, 12.0, 13.0, 1000.0]) # => 1.4826
152
+ DTW::Statistics.standard_deviation([2.0, 4.0, 4.0, 4.0, 5.0]) # => 1.0954451150103321
153
+
154
+ DTW::Resampler.call([[0.0], [10.0]], 3) # => [[0.0], [5.0], [10.0]]
155
+ ```
156
+
157
+ ## In practice
158
+
159
+ [taiwancards](https://github.com/taiwancards/taiwancards) teaches Taiwanese Mandarin and scores
160
+ learner recordings against native templates. The library is used at two stages.
161
+
162
+ **Template construction, offline.** For every syllable-and-tone key the corpus pipeline gathers
163
+ recordings from many speakers. Speaking rate differs, so the MFCC matrices (13 coefficients per
164
+ frame) and the F0 tone contours arrive at different lengths. `DTW.barycenter` reduces each set to
165
+ one prototype — 12 frames for MFCC, 16 for the tone contour — plus the per-frame dispersion that
166
+ becomes the tolerance band.
167
+
168
+ **Scoring, online.** A learner utterance is framed into its own MFCC matrix. `DTW.distance` to the
169
+ prototype center gives a rate-invariant timbre score, so a slow speaker is not penalized for being
170
+ slow. The stored dispersion turns that raw distance into a z-score, so syllables whose realization
171
+ is naturally variable do not raise false alarms.
172
+
173
+ Field recordings contain clipped, truncated, and mislabeled tokens. Median aggregation keeps such a
174
+ minority from dragging the prototype away from the actual articulation target — the property that
175
+ motivated the robust default.
176
+
177
+ Speech is one instance of the general case. Anything sampled as a variable-rate numeric sequence —
178
+ gesture traces, biosignals, machine cycles, pen trajectories — poses the same alignment and
179
+ prototyping problem.
180
+
181
+ ## Complexity
182
+
183
+ | Operation | Time | Memory |
184
+ | --- | --- | --- |
185
+ | Unconstrained alignment | O(n·m·D) | O(n·m) |
186
+ | Banded alignment | O(r·max(n, m)·D) | O(n·m) |
187
+ | Medoid over k sequences | O(min(k, s)²) alignments | O(min(k, s)) |
188
+ | Barycenter, I iterations | O((I + 1)·k·n·m·D) | O(n·m + L·D·k) |
189
+
190
+ `n` and `m` are sequence lengths, `D` the frame dimension, `r` the band radius, `s` the medoid
191
+ sample size, `L` the prototype length.
192
+
193
+ ## Design
194
+
195
+ Strategies are injected, never hard-coded: the local distance (`DTW::Metrics`), the global
196
+ constraint (`DTW::Bands`), the central-tendency aggregator, and the dispersion estimator
197
+ (`DTW::Statistics`). `DTW::Aligner`, `DTW::Medoid`, and `DTW::BarycenterAveraging` are immutable and
198
+ reusable; build them once when aligning many pairs.
199
+
200
+ ```ruby
201
+ aligner = DTW::Aligner.new(metric: :cosine, band: 0.1)
202
+ averaging = DTW::BarycenterAveraging.new(aligner: aligner, aggregator: :mean, iterations: 10)
203
+
204
+ averaging.call(observations, length: 32)
205
+ ```
206
+
207
+ `DTW::Alignment`, `DTW::Barycenter`, and `DTW::Sample` are `Data` value objects: frozen, compared by
208
+ value, copied with `#with`.
209
+
210
+ ## License
211
+
212
+ WTFPL — see [LICENSE](LICENSE).
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DTW
4
+ class Aligner
5
+ attr_reader :metric, :band
6
+
7
+ def initialize(metric: Metrics::DEFAULT, band: Bands::DEFAULT)
8
+ @metric = Metrics.resolve(metric)
9
+ @band = Bands.resolve(band)
10
+ freeze
11
+ end
12
+
13
+ def align(sequence_a, sequence_b)
14
+ frames_a = Sequence.vectorize(sequence_a)
15
+ frames_b = Sequence.vectorize(sequence_b)
16
+ return Alignment::UNDEFINED if frames_a.empty? || frames_b.empty?
17
+
18
+ grid = accumulate(frames_a, frames_b)
19
+ Alignment.new(
20
+ path: backtrack(grid, frames_a.length, frames_b.length),
21
+ cost: grid[frames_a.length][frames_b.length]
22
+ )
23
+ end
24
+
25
+ def distance(sequence_a, sequence_b) = align(sequence_a, sequence_b).distance
26
+
27
+ private
28
+
29
+ def accumulate(frames_a, frames_b)
30
+ rows = frames_a.length
31
+ columns = frames_b.length
32
+ radius = @band.radius(rows, columns)
33
+ grid = Array.new(rows + 1) { Array.new(columns + 1, Float::INFINITY) }
34
+ grid[0][0] = 0.0
35
+
36
+ row_index = 1
37
+ while row_index <= rows
38
+ frame = frames_a[row_index - 1]
39
+ row = grid[row_index]
40
+ previous_row = grid[row_index - 1]
41
+ column_index = [1, row_index - radius].max
42
+ last_column = [columns, row_index + radius].min
43
+
44
+ while column_index <= last_column
45
+ best = previous_row[column_index]
46
+ left = row[column_index - 1]
47
+ best = left if left < best
48
+ diagonal = previous_row[column_index - 1]
49
+ best = diagonal if diagonal < best
50
+ row[column_index] = @metric.call(frame, frames_b[column_index - 1]) + best
51
+ column_index += 1
52
+ end
53
+
54
+ row_index += 1
55
+ end
56
+
57
+ grid
58
+ end
59
+
60
+ def backtrack(grid, rows, columns)
61
+ path = []
62
+ row_index = rows
63
+ column_index = columns
64
+
65
+ while row_index.positive? && column_index.positive?
66
+ path << [row_index - 1, column_index - 1]
67
+ diagonal = grid[row_index - 1][column_index - 1]
68
+ upper = grid[row_index - 1][column_index]
69
+ left = grid[row_index][column_index - 1]
70
+
71
+ if diagonal <= upper && diagonal <= left
72
+ row_index -= 1
73
+ column_index -= 1
74
+ elsif upper <= left
75
+ row_index -= 1
76
+ else
77
+ column_index -= 1
78
+ end
79
+ end
80
+
81
+ path.reverse!
82
+ path.freeze
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DTW
4
+ Alignment = Data.define(:path, :cost) do
5
+ def steps = path.length
6
+
7
+ def distance = steps.zero? ? cost : cost / steps
8
+
9
+ def undefined? = path.empty?
10
+
11
+ def to_a = path
12
+ end
13
+
14
+ Alignment::UNDEFINED = Alignment.new(path: [].freeze, cost: Float::INFINITY)
15
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DTW
4
+ module Bands
5
+ SakoeChiba = Data.define(:ratio) do
6
+ def initialize(ratio:)
7
+ width = Float(ratio)
8
+ raise ArgumentError, "band ratio must be non-negative, got #{width}" if width.negative?
9
+
10
+ super(ratio: width)
11
+ end
12
+
13
+ # The |n - m| + 1 term keeps at least one path feasible when the lengths differ widely.
14
+ def radius(length_a, length_b)
15
+ [(ratio * [length_a, length_b].max).ceil, (length_a - length_b).abs + 1].max
16
+ end
17
+ end
18
+
19
+ module Unconstrained
20
+ def self.radius(length_a, length_b) = [length_a, length_b].max
21
+ end
22
+
23
+ # Admits warping of up to a quarter of the longer sequence, the usual setting for speech.
24
+ DEFAULT_RATIO = 0.25
25
+
26
+ DEFAULT = SakoeChiba.new(ratio: DEFAULT_RATIO)
27
+
28
+ module_function
29
+
30
+ def resolve(band)
31
+ case band
32
+ when Numeric
33
+ SakoeChiba.new(ratio: band)
34
+ when nil, :unconstrained
35
+ Unconstrained
36
+ when :sakoe_chiba
37
+ DEFAULT
38
+ else
39
+ return band if band.respond_to?(:radius)
40
+
41
+ raise(
42
+ UnknownStrategyError,
43
+ "unknown band #{band.inspect}: expected a numeric Sakoe-Chiba ratio, :sakoe_chiba, " \
44
+ ":unconstrained or an object responding to #radius"
45
+ )
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DTW
4
+ Barycenter = Data.define(:center, :dispersion, :count) do
5
+ def length = center.length
6
+
7
+ def dimension
8
+ frame = center.first
9
+ frame.is_a?(Array) ? frame.length : 1
10
+ end
11
+
12
+ def scalar? = !center.first.is_a?(Array)
13
+ end
14
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DTW
4
+ class BarycenterAveraging
5
+ # DBA converges within a few refinement passes; further passes rarely move the prototype.
6
+ DEFAULT_ITERATIONS = 4
7
+
8
+ # Mean absolute update per coordinate below which the prototype counts as settled.
9
+ DEFAULT_TOLERANCE = 1e-4
10
+
11
+ attr_reader :aligner, :iterations, :tolerance
12
+
13
+ def initialize(
14
+ aligner: Aligner.new,
15
+ aggregator: :median,
16
+ dispersion: :median_absolute_deviation,
17
+ iterations: DEFAULT_ITERATIONS,
18
+ tolerance: DEFAULT_TOLERANCE,
19
+ sample_size: Medoid::DEFAULT_SAMPLE_SIZE
20
+ )
21
+ @aligner = aligner
22
+ @aggregator = Statistics.resolve(aggregator)
23
+ @dispersion = Statistics.resolve(dispersion)
24
+ @iterations = Integer(iterations)
25
+ @tolerance = Float(tolerance)
26
+ @medoid = Medoid.new(aligner: aligner, sample_size: sample_size)
27
+
28
+ raise ArgumentError, "iterations must be non-negative, got #{@iterations}" if @iterations.negative?
29
+ raise ArgumentError, "tolerance must be non-negative, got #{@tolerance}" if @tolerance.negative?
30
+
31
+ freeze
32
+ end
33
+
34
+ def call(sequences, length: nil)
35
+ sample = Sample.of(sequences)
36
+ width = Integer(length || sample.mean_length)
37
+ raise ArgumentError, "barycenter length must be positive, got #{width}" unless width.positive?
38
+
39
+ center = refine(Resampler.call(@medoid.call(sample.sequences), width), sample)
40
+ spread = summarize(associate(center, sample), @dispersion) { 0.0 }
41
+
42
+ build(center, spread, sample)
43
+ end
44
+
45
+ private
46
+
47
+ def refine(center, sample)
48
+ @iterations.times do
49
+ updated = summarize(associate(center, sample), @aggregator) { |index, axis| center[index][axis] }
50
+ settled = converged?(center, updated)
51
+ center = updated
52
+ break if settled
53
+ end
54
+
55
+ center
56
+ end
57
+
58
+ def associate(center, sample)
59
+ dimension = sample.dimension
60
+ associations = Array.new(center.length) { Array.new(dimension) { [] } }
61
+
62
+ sample.sequences.each do |frames|
63
+ @aligner.align(center, frames).path.each do |center_index, frame_index|
64
+ frame = frames[frame_index]
65
+ axis = 0
66
+ while axis < dimension
67
+ associations[center_index][axis] << frame[axis]
68
+ axis += 1
69
+ end
70
+ end
71
+ end
72
+
73
+ associations
74
+ end
75
+
76
+ def summarize(associations, estimator)
77
+ associations.each_with_index.map do |axes, index|
78
+ axes.each_with_index.map do |values, axis|
79
+ values.empty? ? yield(index, axis) : estimator.call(values)
80
+ end
81
+ end
82
+ end
83
+
84
+ def converged?(previous, current)
85
+ total = 0.0
86
+ previous.each_with_index do |frame, index|
87
+ frame.each_with_index { |value, axis| total += (value - current[index][axis]).abs }
88
+ end
89
+
90
+ (total / (previous.length * previous.first.length)) < @tolerance
91
+ end
92
+
93
+ def build(center, spread, sample)
94
+ return Barycenter.new(center: center, dispersion: spread, count: sample.count) unless sample.scalar?
95
+
96
+ Barycenter.new(
97
+ center: Sequence.scalarize(center),
98
+ dispersion: Sequence.scalarize(spread),
99
+ count: sample.count
100
+ )
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DTW
4
+ Error = Class.new(StandardError)
5
+
6
+ InvalidSequenceError = Class.new(Error)
7
+
8
+ DimensionMismatchError = Class.new(Error)
9
+
10
+ EmptyInputError = Class.new(Error)
11
+
12
+ UnknownStrategyError = Class.new(Error)
13
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DTW
4
+ class Medoid
5
+ # Exact selection costs O(k^2) alignments, so candidates are capped at a uniform subsample.
6
+ DEFAULT_SAMPLE_SIZE = 12
7
+
8
+ attr_reader :aligner, :sample_size
9
+
10
+ def initialize(aligner: Aligner.new, sample_size: DEFAULT_SAMPLE_SIZE)
11
+ @aligner = aligner
12
+ @sample_size = Integer(sample_size)
13
+ raise ArgumentError, "sample size must be positive, got #{@sample_size}" unless @sample_size.positive?
14
+
15
+ freeze
16
+ end
17
+
18
+ def call(sequences)
19
+ candidates = subsample(Sequence.compact(sequences))
20
+ costs = accumulated_costs(candidates)
21
+
22
+ candidates[costs.index(costs.min)]
23
+ end
24
+
25
+ private
26
+
27
+ def subsample(candidates)
28
+ return candidates if candidates.length <= @sample_size
29
+
30
+ count = candidates.length
31
+ Array.new(@sample_size) { |index| candidates[index * count / @sample_size] }
32
+ end
33
+
34
+ def accumulated_costs(candidates)
35
+ costs = Array.new(candidates.length, 0.0)
36
+
37
+ candidates.each_with_index do |candidate, index|
38
+ ((index + 1)...candidates.length).each do |other|
39
+ separation = @aligner.distance(candidate, candidates[other])
40
+ costs[index] += separation
41
+ costs[other] += separation
42
+ end
43
+ end
44
+
45
+ costs
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DTW
4
+ module Metrics
5
+ module_function
6
+
7
+ def assert_conformable!(left, right)
8
+ return if left.length == right.length
9
+
10
+ raise(
11
+ DimensionMismatchError,
12
+ "local distance requires equal dimensions, got #{left.length} and #{right.length}"
13
+ )
14
+ end
15
+
16
+ module Euclidean
17
+ def self.call(left, right)
18
+ Metrics.assert_conformable!(left, right)
19
+
20
+ total = 0.0
21
+ axis = 0
22
+ while axis < left.length
23
+ difference = left[axis] - right[axis]
24
+ total += difference * difference
25
+ axis += 1
26
+ end
27
+
28
+ Math.sqrt(total)
29
+ end
30
+ end
31
+
32
+ module Manhattan
33
+ def self.call(left, right)
34
+ Metrics.assert_conformable!(left, right)
35
+
36
+ total = 0.0
37
+ axis = 0
38
+ while axis < left.length
39
+ total += (left[axis] - right[axis]).abs
40
+ axis += 1
41
+ end
42
+
43
+ total
44
+ end
45
+ end
46
+
47
+ module Chebyshev
48
+ def self.call(left, right)
49
+ Metrics.assert_conformable!(left, right)
50
+
51
+ largest = 0.0
52
+ axis = 0
53
+ while axis < left.length
54
+ difference = (left[axis] - right[axis]).abs
55
+ largest = difference if difference > largest
56
+ axis += 1
57
+ end
58
+
59
+ largest.to_f
60
+ end
61
+ end
62
+
63
+ module Cosine
64
+ def self.call(left, right)
65
+ Metrics.assert_conformable!(left, right)
66
+
67
+ product = 0.0
68
+ left_norm = 0.0
69
+ right_norm = 0.0
70
+ axis = 0
71
+ while axis < left.length
72
+ product += left[axis] * right[axis]
73
+ left_norm += left[axis] * left[axis]
74
+ right_norm += right[axis] * right[axis]
75
+ axis += 1
76
+ end
77
+
78
+ return 0.0 if left_norm.zero? && right_norm.zero?
79
+ return 1.0 if left_norm.zero? || right_norm.zero?
80
+
81
+ 1.0 - (product / Math.sqrt(left_norm * right_norm))
82
+ end
83
+ end
84
+
85
+ REGISTRY = {
86
+ euclidean: Euclidean,
87
+ manhattan: Manhattan,
88
+ chebyshev: Chebyshev,
89
+ cosine: Cosine
90
+ }.freeze
91
+
92
+ DEFAULT = Euclidean
93
+
94
+ def resolve(metric) = Resolver.call(metric, REGISTRY, "metric")
95
+ end
96
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DTW
4
+ module Resampler
5
+ module_function
6
+
7
+ def call(frames, length)
8
+ raise ArgumentError, "resample length must be positive, got #{length}" unless length.positive?
9
+ return [] if frames.empty?
10
+ return Array.new(length) { frames.first.map(&:to_f) } if frames.length == 1 || length == 1
11
+
12
+ last = frames.length - 1
13
+
14
+ Array.new(length) do |index|
15
+ position = index.to_f * last / (length - 1)
16
+ lower = position.floor
17
+ upper = [lower + 1, last].min
18
+ interpolate(frames[lower], frames[upper], position - lower)
19
+ end
20
+ end
21
+
22
+ def interpolate(from, to, fraction)
23
+ Array.new(from.length) { |axis| (from[axis] * (1.0 - fraction)) + (to[axis] * fraction) }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DTW
4
+ module Resolver
5
+ module_function
6
+
7
+ def call(value, registry, kind)
8
+ return value if value.respond_to?(:call)
9
+
10
+ registry.fetch(value) do
11
+ raise(
12
+ UnknownStrategyError,
13
+ "unknown #{kind} #{value.inspect}: expected one of #{registry.keys.join(", ")} " \
14
+ "or an object responding to #call"
15
+ )
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DTW
4
+ Sample = Data.define(:sequences, :dimension, :scalar) do
5
+ def self.of(sequences)
6
+ usable = Sequence.compact(sequences)
7
+ collection = usable.map { |sequence| Sequence.vectorize(sequence) }
8
+
9
+ new(
10
+ sequences: collection,
11
+ dimension: Sequence.assert_uniform_dimension!(collection),
12
+ scalar: usable.all? { |sequence| Sequence.scalar?(sequence) }
13
+ )
14
+ end
15
+
16
+ def count = sequences.length
17
+
18
+ def scalar? = scalar
19
+
20
+ def mean_length = [(sequences.sum(&:length).to_f / count).round, 1].max
21
+ end
22
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DTW
4
+ module Sequence
5
+ module_function
6
+
7
+ def usable?(sequence) = sequence.is_a?(Array) && !sequence.empty?
8
+
9
+ def scalar?(sequence) = usable?(sequence) && sequence.first.is_a?(Numeric)
10
+
11
+ def vectorize(sequence)
12
+ raise InvalidSequenceError, "expected an Array of frames, got #{sequence.class}" unless sequence.is_a?(Array)
13
+
14
+ scalar?(sequence) ? sequence.map { |value| [value] } : sequence
15
+ end
16
+
17
+ def scalarize(frames) = frames.map(&:first)
18
+
19
+ def compact(sequences)
20
+ usable = Array(sequences).select { |sequence| usable?(sequence) }
21
+ raise EmptyInputError, "expected at least one non-empty sequence, got none" if usable.empty?
22
+
23
+ usable
24
+ end
25
+
26
+ def dimension_of(frames)
27
+ frame = frames.first
28
+ raise InvalidSequenceError, "expected a vector frame, got #{frame.class}" unless frame.is_a?(Array)
29
+
30
+ frame.length
31
+ end
32
+
33
+ def assert_uniform_dimension!(collection)
34
+ dimension = dimension_of(collection.first)
35
+
36
+ collection.each do |frames|
37
+ frames.each do |frame|
38
+ next if frame.is_a?(Array) && frame.length == dimension
39
+
40
+ raise DimensionMismatchError, "all frames must share dimension #{dimension}, got #{frame.inspect}"
41
+ end
42
+ end
43
+
44
+ dimension
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DTW
4
+ module Statistics
5
+ # 1 / Phi^-1(3/4). Scaling the median absolute deviation by it makes the
6
+ # statistic a consistent estimator of sigma for normally distributed data.
7
+ NORMAL_CONSISTENCY_SCALE = 1.4826
8
+
9
+ module_function
10
+
11
+ def median(values)
12
+ return nil if values.empty?
13
+
14
+ sorted = values.sort
15
+ middle = sorted.length / 2
16
+ sorted.length.odd? ? sorted[middle].to_f : 0.5 * (sorted[middle - 1] + sorted[middle])
17
+ end
18
+
19
+ def mean(values)
20
+ return nil if values.empty?
21
+
22
+ values.sum(0.0) / values.length
23
+ end
24
+
25
+ def median_absolute_deviation(values)
26
+ return 0.0 if values.length < 2
27
+
28
+ center = median(values)
29
+ NORMAL_CONSISTENCY_SCALE * median(values.map { |value| (value - center).abs })
30
+ end
31
+
32
+ def standard_deviation(values)
33
+ return 0.0 if values.length < 2
34
+
35
+ center = mean(values)
36
+ Math.sqrt(values.sum(0.0) { |value| (value - center) ** 2 } / (values.length - 1))
37
+ end
38
+
39
+ ESTIMATORS = {
40
+ median: method(:median),
41
+ mean: method(:mean),
42
+ median_absolute_deviation: method(:median_absolute_deviation),
43
+ mad: method(:median_absolute_deviation),
44
+ standard_deviation: method(:standard_deviation)
45
+ }.freeze
46
+
47
+ def resolve(estimator) = Resolver.call(estimator, ESTIMATORS, "estimator")
48
+ end
49
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DTW
4
+ VERSION = "1.0.0"
5
+ end
data/lib/dtwrb.rb ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dtwrb/version"
4
+ require_relative "dtwrb/errors"
5
+ require_relative "dtwrb/resolver"
6
+ require_relative "dtwrb/sequence"
7
+ require_relative "dtwrb/sample"
8
+ require_relative "dtwrb/statistics"
9
+ require_relative "dtwrb/metrics"
10
+ require_relative "dtwrb/bands"
11
+ require_relative "dtwrb/alignment"
12
+ require_relative "dtwrb/aligner"
13
+ require_relative "dtwrb/resampler"
14
+ require_relative "dtwrb/medoid"
15
+ require_relative "dtwrb/barycenter"
16
+ require_relative "dtwrb/barycenter_averaging"
17
+
18
+ module DTW
19
+ module_function
20
+
21
+ def align(sequence_a, sequence_b, metric: Metrics::DEFAULT, band: Bands::DEFAULT)
22
+ Aligner.new(metric: metric, band: band).align(sequence_a, sequence_b)
23
+ end
24
+
25
+ def distance(sequence_a, sequence_b, metric: Metrics::DEFAULT, band: Bands::DEFAULT)
26
+ Aligner.new(metric: metric, band: band).distance(sequence_a, sequence_b)
27
+ end
28
+
29
+ def medoid(sequences, metric: Metrics::DEFAULT, band: Bands::DEFAULT, sample_size: Medoid::DEFAULT_SAMPLE_SIZE)
30
+ Medoid.new(aligner: Aligner.new(metric: metric, band: band), sample_size: sample_size).call(sequences)
31
+ end
32
+
33
+ def barycenter(sequences, length: nil, metric: Metrics::DEFAULT, band: Bands::DEFAULT, **options)
34
+ averaging = BarycenterAveraging.new(aligner: Aligner.new(metric: metric, band: band), **options)
35
+ averaging.call(sequences, length: length)
36
+ end
37
+ end
data/sig/dtwrb.rbs ADDED
@@ -0,0 +1,199 @@
1
+ module DTW
2
+ VERSION: String
3
+
4
+ type frame = ::Array[::Numeric]
5
+ type frames = ::Array[frame]
6
+ type sequence = ::Array[untyped]
7
+ type cell = [::Integer, ::Integer]
8
+
9
+ interface _Metric
10
+ def call: (frame, frame) -> ::Float
11
+ end
12
+
13
+ interface _Band
14
+ def radius: (::Integer, ::Integer) -> ::Integer
15
+ end
16
+
17
+ interface _Estimator
18
+ def call: (::Array[::Numeric]) -> ::Numeric?
19
+ end
20
+
21
+ type metric = _Metric | ::Symbol
22
+ type band = _Band | ::Symbol | ::Numeric | nil
23
+ type estimator = _Estimator | ::Symbol
24
+
25
+ class Error < ::StandardError
26
+ end
27
+
28
+ class InvalidSequenceError < Error
29
+ end
30
+
31
+ class DimensionMismatchError < Error
32
+ end
33
+
34
+ class EmptyInputError < Error
35
+ end
36
+
37
+ class UnknownStrategyError < Error
38
+ end
39
+
40
+ def self.align: (sequence, sequence, ?metric: metric, ?band: band) -> Alignment
41
+ def self.distance: (sequence, sequence, ?metric: metric, ?band: band) -> ::Float
42
+ def self.medoid: (::Array[untyped], ?metric: metric, ?band: band, ?sample_size: ::Integer) -> sequence
43
+ def self.barycenter: (::Array[untyped], ?length: ::Integer?, ?metric: metric, ?band: band, **untyped) -> Barycenter
44
+
45
+ module Resolver
46
+ def self.call: (untyped, ::Hash[::Symbol, untyped], ::String) -> untyped
47
+ end
48
+
49
+ module Sequence
50
+ def self.usable?: (untyped) -> bool
51
+ def self.scalar?: (untyped) -> bool
52
+ def self.vectorize: (sequence) -> frames
53
+ def self.scalarize: (frames) -> ::Array[::Numeric]
54
+ def self.compact: (untyped) -> ::Array[sequence]
55
+ def self.dimension_of: (frames) -> ::Integer
56
+ def self.assert_uniform_dimension!: (::Array[frames]) -> ::Integer
57
+ end
58
+
59
+ class Sample
60
+ attr_reader sequences: ::Array[frames]
61
+ attr_reader dimension: ::Integer
62
+ attr_reader scalar: bool
63
+
64
+ def self.of: (::Array[untyped]) -> Sample
65
+ def self.new: (sequences: ::Array[frames], dimension: ::Integer, scalar: bool) -> Sample
66
+ def count: () -> ::Integer
67
+ def scalar?: () -> bool
68
+ def mean_length: () -> ::Integer
69
+ def with: (**untyped) -> Sample
70
+ def to_h: () -> ::Hash[::Symbol, untyped]
71
+ end
72
+
73
+ module Statistics
74
+ NORMAL_CONSISTENCY_SCALE: ::Float
75
+ ESTIMATORS: ::Hash[::Symbol, _Estimator]
76
+
77
+ def self.median: (::Array[::Numeric]) -> ::Float?
78
+ def self.mean: (::Array[::Numeric]) -> ::Float?
79
+ def self.median_absolute_deviation: (::Array[::Numeric]) -> ::Float
80
+ def self.standard_deviation: (::Array[::Numeric]) -> ::Float
81
+ def self.resolve: (estimator) -> _Estimator
82
+ end
83
+
84
+ module Metrics
85
+ REGISTRY: ::Hash[::Symbol, _Metric]
86
+ DEFAULT: _Metric
87
+
88
+ def self.assert_conformable!: (frame, frame) -> void
89
+ def self.resolve: (metric) -> _Metric
90
+
91
+ module Euclidean
92
+ def self.call: (frame, frame) -> ::Float
93
+ end
94
+
95
+ module Manhattan
96
+ def self.call: (frame, frame) -> ::Float
97
+ end
98
+
99
+ module Chebyshev
100
+ def self.call: (frame, frame) -> ::Float
101
+ end
102
+
103
+ module Cosine
104
+ def self.call: (frame, frame) -> ::Float
105
+ end
106
+ end
107
+
108
+ module Bands
109
+ DEFAULT_RATIO: ::Float
110
+ DEFAULT: SakoeChiba
111
+
112
+ def self.resolve: (band) -> _Band
113
+
114
+ class SakoeChiba
115
+ attr_reader ratio: ::Float
116
+
117
+ def self.new: (ratio: ::Numeric) -> SakoeChiba
118
+ def radius: (::Integer, ::Integer) -> ::Integer
119
+ def with: (**untyped) -> SakoeChiba
120
+ def to_h: () -> ::Hash[::Symbol, untyped]
121
+ end
122
+
123
+ module Unconstrained
124
+ def self.radius: (::Integer, ::Integer) -> ::Integer
125
+ end
126
+ end
127
+
128
+ class Alignment
129
+ attr_reader path: ::Array[cell]
130
+ attr_reader cost: ::Float
131
+
132
+ UNDEFINED: Alignment
133
+
134
+ def self.new: (path: ::Array[cell], cost: ::Float) -> Alignment
135
+ def steps: () -> ::Integer
136
+ def distance: () -> ::Float
137
+ def undefined?: () -> bool
138
+ def to_a: () -> ::Array[cell]
139
+ def with: (**untyped) -> Alignment
140
+ def to_h: () -> ::Hash[::Symbol, untyped]
141
+ end
142
+
143
+ class Aligner
144
+ attr_reader metric: _Metric
145
+ attr_reader band: _Band
146
+
147
+ def initialize: (?metric: metric, ?band: band) -> void
148
+ def align: (sequence, sequence) -> Alignment
149
+ def distance: (sequence, sequence) -> ::Float
150
+ end
151
+
152
+ module Resampler
153
+ def self.call: (frames, ::Integer) -> frames
154
+ def self.interpolate: (frame, frame, ::Float) -> frame
155
+ end
156
+
157
+ class Medoid
158
+ DEFAULT_SAMPLE_SIZE: ::Integer
159
+
160
+ attr_reader aligner: Aligner
161
+ attr_reader sample_size: ::Integer
162
+
163
+ def initialize: (?aligner: Aligner, ?sample_size: ::Integer) -> void
164
+ def call: (::Array[untyped]) -> sequence
165
+ end
166
+
167
+ class Barycenter
168
+ attr_reader center: ::Array[untyped]
169
+ attr_reader dispersion: ::Array[untyped]
170
+ attr_reader count: ::Integer
171
+
172
+ def self.new: (center: ::Array[untyped], dispersion: ::Array[untyped], count: ::Integer) -> Barycenter
173
+ def length: () -> ::Integer
174
+ def dimension: () -> ::Integer
175
+ def scalar?: () -> bool
176
+ def with: (**untyped) -> Barycenter
177
+ def to_h: () -> ::Hash[::Symbol, untyped]
178
+ end
179
+
180
+ class BarycenterAveraging
181
+ DEFAULT_ITERATIONS: ::Integer
182
+ DEFAULT_TOLERANCE: ::Float
183
+
184
+ attr_reader aligner: Aligner
185
+ attr_reader iterations: ::Integer
186
+ attr_reader tolerance: ::Float
187
+
188
+ def initialize: (
189
+ ?aligner: Aligner,
190
+ ?aggregator: estimator,
191
+ ?dispersion: estimator,
192
+ ?iterations: ::Integer,
193
+ ?tolerance: ::Numeric,
194
+ ?sample_size: ::Integer
195
+ ) -> void
196
+
197
+ def call: (::Array[untyped], ?length: ::Integer?) -> Barycenter
198
+ end
199
+ end
metadata ADDED
@@ -0,0 +1,110 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dtwrb
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Den Patin
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rake
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '13.4'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '13.4'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rbs
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '4.1'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '4.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rspec
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.13'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.13'
54
+ description: Elastic alignment of numeric time series under Dynamic Time Warping with
55
+ a Sakoe-Chiba band constraint, DTW Barycenter Averaging (DBA) for computing a prototype
56
+ sequence from a set of variable-length observations, approximate medoid selection,
57
+ linear resampling and robust dispersion estimation via the median absolute deviation.
58
+ Pure Ruby, no dependencies. Pluggable local distance (Euclidean, Manhattan, Chebyshev,
59
+ cosine) and central-tendency estimator (median, arithmetic mean).
60
+ email: hi@dpat.in
61
+ executables: []
62
+ extensions: []
63
+ extra_rdoc_files: []
64
+ files:
65
+ - CHANGELOG.md
66
+ - LICENSE
67
+ - README.md
68
+ - lib/dtwrb.rb
69
+ - lib/dtwrb/aligner.rb
70
+ - lib/dtwrb/alignment.rb
71
+ - lib/dtwrb/bands.rb
72
+ - lib/dtwrb/barycenter.rb
73
+ - lib/dtwrb/barycenter_averaging.rb
74
+ - lib/dtwrb/errors.rb
75
+ - lib/dtwrb/medoid.rb
76
+ - lib/dtwrb/metrics.rb
77
+ - lib/dtwrb/resampler.rb
78
+ - lib/dtwrb/resolver.rb
79
+ - lib/dtwrb/sample.rb
80
+ - lib/dtwrb/sequence.rb
81
+ - lib/dtwrb/statistics.rb
82
+ - lib/dtwrb/version.rb
83
+ - sig/dtwrb.rbs
84
+ homepage: https://github.com/taiwancards/dtwrb
85
+ licenses:
86
+ - WTFPL
87
+ metadata:
88
+ source_code_uri: https://github.com/taiwancards/dtwrb
89
+ changelog_uri: https://github.com/taiwancards/dtwrb/blob/main/CHANGELOG.md
90
+ bug_tracker_uri: https://github.com/taiwancards/dtwrb/issues
91
+ rubygems_mfa_required: 'true'
92
+ rdoc_options: []
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '3.2'
100
+ required_rubygems_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ requirements: []
106
+ rubygems_version: 4.0.16
107
+ specification_version: 4
108
+ summary: Dynamic Time Warping, DBA barycenter averaging and robust dispersion for
109
+ numeric sequences
110
+ test_files: []