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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a69f04b76a452056c2749e7f695a8d9adcb3f1469e4f672ff2f9a5f95c7ab116
4
- data.tar.gz: 0bc45f0c9b9947b6be08ec1c26fb318d6e6a18c47ef2c789a60d869c5889a709
3
+ metadata.gz: 46ff5e2b8f1059ffe352f4499f9858ca31a6503ac3f63c1ab17f79615be514a1
4
+ data.tar.gz: 6de49d664f809e686bd47902a4485162bcaef6ccc991814503af2dc7ee19bcd2
5
5
  SHA512:
6
- metadata.gz: 2d93d6b736d7655571db2a0af148d7f35e5b74d26bb79d3313542c3ef2d31f15e01b522b961d32500e259bb636cefa3cea3e7cc0c43b1b88686f955460554b5f
7
- data.tar.gz: 3bd0dcd7d9c90ee8280f031f6621b5e8d220b79af807e766e2077c8bb94d1b64fc36a115a92d8d9490c59eda14e152b0709ac0568ef63aed2436d2c831a01105
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 | 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 |
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, including for sequences of very
136
- unequal length.
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
- | 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` |
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 | 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) |
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
- radius = @band.radius(rows, columns)
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
- column_index = [1, row_index - radius].max
42
- last_column = [columns, row_index + radius].min
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DTW
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  end
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.0.0
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.16
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