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 +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE +13 -0
- data/README.md +212 -0
- data/lib/dtwrb/aligner.rb +85 -0
- data/lib/dtwrb/alignment.rb +15 -0
- data/lib/dtwrb/bands.rb +49 -0
- data/lib/dtwrb/barycenter.rb +14 -0
- data/lib/dtwrb/barycenter_averaging.rb +103 -0
- data/lib/dtwrb/errors.rb +13 -0
- data/lib/dtwrb/medoid.rb +48 -0
- data/lib/dtwrb/metrics.rb +96 -0
- data/lib/dtwrb/resampler.rb +26 -0
- data/lib/dtwrb/resolver.rb +19 -0
- data/lib/dtwrb/sample.rb +22 -0
- data/lib/dtwrb/sequence.rb +47 -0
- data/lib/dtwrb/statistics.rb +49 -0
- data/lib/dtwrb/version.rb +5 -0
- data/lib/dtwrb.rb +37 -0
- data/sig/dtwrb.rbs +199 -0
- metadata +110 -0
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
|
data/lib/dtwrb/bands.rb
ADDED
|
@@ -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
|
data/lib/dtwrb/errors.rb
ADDED
|
@@ -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
|
data/lib/dtwrb/medoid.rb
ADDED
|
@@ -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
|
data/lib/dtwrb/sample.rb
ADDED
|
@@ -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
|
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: []
|