dtwrb 1.0.0 → 1.1.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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +39 -20
- data/lib/dtwrb/aligner.rb +16 -3
- data/lib/dtwrb/bands.rb +42 -1
- data/lib/dtwrb/version.rb +1 -1
- data/sig/dtwrb.rbs +14 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 46ff5e2b8f1059ffe352f4499f9858ca31a6503ac3f63c1ab17f79615be514a1
|
|
4
|
+
data.tar.gz: 6de49d664f809e686bd47902a4485162bcaef6ccc991814503af2dc7ee19bcd2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9a9397d37e53c60dcaff865ba61e6acce1e1f1bdd93b7455d001f0755546078d68df1c45f5b8d80246472741df30ce2953fe9efef27ca27372b1ac6315fd9094
|
|
7
|
+
data.tar.gz: 9813d307282cffda759a1178cabb2062f7f55705be0bee415abda0e63d089e18a01fb436b3394a235bb63d659db24aed9620a1867e54a565606275d5cd89bda8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.0 — 2026-08-22
|
|
4
|
+
|
|
5
|
+
- `DTW::Bands::Slanted`: a global constraint that follows the diagonal of the rectangle, so the
|
|
6
|
+
radius keeps constraining the path when the sequences differ in length. The literal Sakoe–Chiba
|
|
7
|
+
band widens to `|n − m| + 1` in that case and stops bounding the warping; on a `40 × 140` pair a
|
|
8
|
+
ratio of `0.25` asks for 35 cells either side but gets 101. Reachable as `:slanted`.
|
|
9
|
+
- Bands may now expose `#bounds(i, n, m) -> [Integer, Integer]` for per-row limits. `#radius` alone
|
|
10
|
+
is still enough, and `DTW::Bands::SakoeChiba` is unchanged.
|
|
11
|
+
|
|
3
12
|
## 1.0.0 — 2026-08-01
|
|
4
13
|
|
|
5
14
|
Initial public release.
|
data/README.md
CHANGED
|
@@ -111,12 +111,12 @@ DTW.medoid(observations)
|
|
|
111
111
|
|
|
112
112
|
Any object that responds to `#call(frame_a, frame_b) -> Float` is accepted.
|
|
113
113
|
|
|
114
|
-
| Name
|
|
115
|
-
|
|
|
116
|
-
| `:euclidean` | L²
|
|
117
|
-
| `:manhattan` | L¹
|
|
118
|
-
| `:chebyshev` | L∞
|
|
119
|
-
| `:cosine`
|
|
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
120
|
|
|
121
121
|
```ruby
|
|
122
122
|
DTW.distance(a, b, metric: :manhattan)
|
|
@@ -132,18 +132,37 @@ which bounds the admissible warping and reduces the cost matrix to a diagonal st
|
|
|
132
132
|
r = max( ⌈ratio · max(n, m)⌉, |n − m| + 1 )
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
-
The second term guarantees that at least one path stays feasible
|
|
136
|
-
|
|
135
|
+
The second term guarantees that at least one path stays feasible. It also means that the band
|
|
136
|
+
stops constraining once the lengths diverge: at `n = 40, m = 140` the radius is at least 101
|
|
137
|
+
whatever ratio is asked for, because the strip must reach the far corner from the main diagonal.
|
|
137
138
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
139
|
+
The slanted band removes that limitation by following the diagonal of the rectangle rather than
|
|
140
|
+
the main diagonal, so the radius keeps its meaning at any length ratio:
|
|
141
|
+
|
|
142
|
+
```text
|
|
143
|
+
centre(i) = 1 + (i − 1) · (m − 1) / (n − 1), |j − centre(i)| ≤ r
|
|
144
|
+
r = max( ⌈ratio · max(n, m)⌉, ⌈½ · (m − 1) / (n − 1)⌉, 1 )
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The second term is half a step of the diagonal, below which consecutive rows no longer overlap
|
|
148
|
+
and no path exists. On the same `40 × 140` pair a ratio of `0.25` gives `r = 35` instead of `101`.
|
|
149
|
+
|
|
150
|
+
| Value | Meaning |
|
|
151
|
+
| ---------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
152
|
+
| `0.25` | default ratio |
|
|
153
|
+
| any `Numeric` | Sakoe–Chiba band with that ratio |
|
|
154
|
+
| `:sakoe_chiba` | Sakoe–Chiba band at the default ratio |
|
|
155
|
+
| `:slanted` | slanted band at the default ratio |
|
|
156
|
+
| `:unconstrained` | full cost matrix |
|
|
157
|
+
| custom | any object that responds to `#radius(n, m) -> Integer`, optionally `#bounds(i, n, m) -> [Integer, Integer]` |
|
|
144
158
|
|
|
145
159
|
Narrowing the band can only raise the accumulated cost, never lower it.
|
|
146
160
|
|
|
161
|
+
```ruby
|
|
162
|
+
DTW::Bands::SakoeChiba.new(ratio: 0.25).radius(40, 140) # => 101
|
|
163
|
+
DTW::Bands::Slanted.new(ratio: 0.25).radius(40, 140) # => 35
|
|
164
|
+
```
|
|
165
|
+
|
|
147
166
|
## Statistics and resampling
|
|
148
167
|
|
|
149
168
|
```ruby
|
|
@@ -180,12 +199,12 @@ prototyping problem.
|
|
|
180
199
|
|
|
181
200
|
## Complexity
|
|
182
201
|
|
|
183
|
-
| Operation
|
|
184
|
-
|
|
|
185
|
-
| Unconstrained alignment
|
|
186
|
-
| Banded alignment
|
|
187
|
-
| Medoid over k sequences
|
|
188
|
-
| Barycenter, I iterations | O((I + 1)·k·n·m·D)
|
|
202
|
+
| Operation | Time | Memory |
|
|
203
|
+
| ------------------------ | ------------------------ | -------------- |
|
|
204
|
+
| Unconstrained alignment | O(n·m·D) | O(n·m) |
|
|
205
|
+
| Banded alignment | O(r·max(n, m)·D) | O(n·m) |
|
|
206
|
+
| Medoid over k sequences | O(min(k, s)²) alignments | O(min(k, s)) |
|
|
207
|
+
| Barycenter, I iterations | O((I + 1)·k·n·m·D) | O(n·m + L·D·k) |
|
|
189
208
|
|
|
190
209
|
`n` and `m` are sequence lengths, `D` the frame dimension, `r` the band radius, `s` the medoid
|
|
191
210
|
sample size, `L` the prototype length.
|
data/lib/dtwrb/aligner.rb
CHANGED
|
@@ -29,7 +29,7 @@ module DTW
|
|
|
29
29
|
def accumulate(frames_a, frames_b)
|
|
30
30
|
rows = frames_a.length
|
|
31
31
|
columns = frames_b.length
|
|
32
|
-
|
|
32
|
+
limits = window(rows, columns)
|
|
33
33
|
grid = Array.new(rows + 1) { Array.new(columns + 1, Float::INFINITY) }
|
|
34
34
|
grid[0][0] = 0.0
|
|
35
35
|
|
|
@@ -38,8 +38,9 @@ module DTW
|
|
|
38
38
|
frame = frames_a[row_index - 1]
|
|
39
39
|
row = grid[row_index]
|
|
40
40
|
previous_row = grid[row_index - 1]
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
first, last = limits[row_index]
|
|
42
|
+
column_index = first
|
|
43
|
+
last_column = last
|
|
43
44
|
|
|
44
45
|
while column_index <= last_column
|
|
45
46
|
best = previous_row[column_index]
|
|
@@ -57,6 +58,18 @@ module DTW
|
|
|
57
58
|
grid
|
|
58
59
|
end
|
|
59
60
|
|
|
61
|
+
def window(rows, columns)
|
|
62
|
+
if @band.respond_to?(:bounds)
|
|
63
|
+
Array.new(rows + 1) do |row|
|
|
64
|
+
first, last = @band.bounds(row, rows, columns)
|
|
65
|
+
[[1, first].max, [columns, last].min]
|
|
66
|
+
end
|
|
67
|
+
else
|
|
68
|
+
radius = @band.radius(rows, columns)
|
|
69
|
+
Array.new(rows + 1) { |row| [[1, row - radius].max, [columns, row + radius].min] }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
60
73
|
def backtrack(grid, rows, columns)
|
|
61
74
|
path = []
|
|
62
75
|
row_index = rows
|
data/lib/dtwrb/bands.rb
CHANGED
|
@@ -14,10 +14,47 @@ module DTW
|
|
|
14
14
|
def radius(length_a, length_b)
|
|
15
15
|
[(ratio * [length_a, length_b].max).ceil, (length_a - length_b).abs + 1].max
|
|
16
16
|
end
|
|
17
|
+
|
|
18
|
+
def bounds(row, rows, columns)
|
|
19
|
+
width = radius(rows, columns)
|
|
20
|
+
[row - width, row + width]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The band follows the diagonal of the rectangle, so the radius keeps constraining
|
|
25
|
+
# the path when the two sequences differ in length.
|
|
26
|
+
Slanted = Data.define(:ratio) do
|
|
27
|
+
def initialize(ratio:)
|
|
28
|
+
width = Float(ratio)
|
|
29
|
+
raise ArgumentError, "band ratio must be non-negative, got #{width}" if width.negative?
|
|
30
|
+
|
|
31
|
+
super(ratio: width)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def radius(rows, columns)
|
|
35
|
+
[(ratio * [rows, columns].max).ceil, reachable(rows, columns)].max
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def bounds(row, rows, columns)
|
|
39
|
+
return [1, columns] if rows < 2 || columns < 2
|
|
40
|
+
|
|
41
|
+
width = radius(rows, columns)
|
|
42
|
+
centre = 1.0 + ((row - 1) * (columns - 1).fdiv(rows - 1))
|
|
43
|
+
[(centre - width).ceil, (centre + width).floor]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Half a step of the diagonal, below which consecutive rows no longer overlap.
|
|
47
|
+
def reachable(rows, columns)
|
|
48
|
+
return 1 if rows < 2 || columns < 2
|
|
49
|
+
|
|
50
|
+
[((columns - 1).fdiv(rows - 1) / 2).ceil, 1].max
|
|
51
|
+
end
|
|
17
52
|
end
|
|
18
53
|
|
|
19
54
|
module Unconstrained
|
|
20
55
|
def self.radius(length_a, length_b) = [length_a, length_b].max
|
|
56
|
+
|
|
57
|
+
def self.bounds(_row, _rows, columns) = [1, columns]
|
|
21
58
|
end
|
|
22
59
|
|
|
23
60
|
# Admits warping of up to a quarter of the longer sequence, the usual setting for speech.
|
|
@@ -25,6 +62,8 @@ module DTW
|
|
|
25
62
|
|
|
26
63
|
DEFAULT = SakoeChiba.new(ratio: DEFAULT_RATIO)
|
|
27
64
|
|
|
65
|
+
SLANTED = Slanted.new(ratio: DEFAULT_RATIO)
|
|
66
|
+
|
|
28
67
|
module_function
|
|
29
68
|
|
|
30
69
|
def resolve(band)
|
|
@@ -35,13 +74,15 @@ module DTW
|
|
|
35
74
|
Unconstrained
|
|
36
75
|
when :sakoe_chiba
|
|
37
76
|
DEFAULT
|
|
77
|
+
when :slanted
|
|
78
|
+
SLANTED
|
|
38
79
|
else
|
|
39
80
|
return band if band.respond_to?(:radius)
|
|
40
81
|
|
|
41
82
|
raise(
|
|
42
83
|
UnknownStrategyError,
|
|
43
84
|
"unknown band #{band.inspect}: expected a numeric Sakoe-Chiba ratio, :sakoe_chiba, " \
|
|
44
|
-
":unconstrained or an object responding to #radius"
|
|
85
|
+
":slanted, :unconstrained or an object responding to #radius"
|
|
45
86
|
)
|
|
46
87
|
end
|
|
47
88
|
end
|
data/lib/dtwrb/version.rb
CHANGED
data/sig/dtwrb.rbs
CHANGED
|
@@ -108,6 +108,7 @@ module DTW
|
|
|
108
108
|
module Bands
|
|
109
109
|
DEFAULT_RATIO: ::Float
|
|
110
110
|
DEFAULT: SakoeChiba
|
|
111
|
+
SLANTED: Slanted
|
|
111
112
|
|
|
112
113
|
def self.resolve: (band) -> _Band
|
|
113
114
|
|
|
@@ -116,12 +117,25 @@ module DTW
|
|
|
116
117
|
|
|
117
118
|
def self.new: (ratio: ::Numeric) -> SakoeChiba
|
|
118
119
|
def radius: (::Integer, ::Integer) -> ::Integer
|
|
120
|
+
def bounds: (::Integer, ::Integer, ::Integer) -> [::Integer, ::Integer]
|
|
119
121
|
def with: (**untyped) -> SakoeChiba
|
|
120
122
|
def to_h: () -> ::Hash[::Symbol, untyped]
|
|
121
123
|
end
|
|
122
124
|
|
|
125
|
+
class Slanted
|
|
126
|
+
attr_reader ratio: ::Float
|
|
127
|
+
|
|
128
|
+
def self.new: (ratio: ::Numeric) -> Slanted
|
|
129
|
+
def radius: (::Integer, ::Integer) -> ::Integer
|
|
130
|
+
def bounds: (::Integer, ::Integer, ::Integer) -> [::Integer, ::Integer]
|
|
131
|
+
def reachable: (::Integer, ::Integer) -> ::Integer
|
|
132
|
+
def with: (**untyped) -> Slanted
|
|
133
|
+
def to_h: () -> ::Hash[::Symbol, untyped]
|
|
134
|
+
end
|
|
135
|
+
|
|
123
136
|
module Unconstrained
|
|
124
137
|
def self.radius: (::Integer, ::Integer) -> ::Integer
|
|
138
|
+
def self.bounds: (::Integer, ::Integer, ::Integer) -> [::Integer, ::Integer]
|
|
125
139
|
end
|
|
126
140
|
end
|
|
127
141
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dtwrb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Den Patin
|
|
@@ -103,7 +103,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
103
103
|
- !ruby/object:Gem::Version
|
|
104
104
|
version: '0'
|
|
105
105
|
requirements: []
|
|
106
|
-
rubygems_version: 4.0.
|
|
106
|
+
rubygems_version: 4.0.17
|
|
107
107
|
specification_version: 4
|
|
108
108
|
summary: Dynamic Time Warping, DBA barycenter averaging and robust dispersion for
|
|
109
109
|
numeric sequences
|