Skip to content

Commit 4ca43c6

Browse files
authored
Add PELT (#992)
* Add PELT * Enhance PELT class to support custom cost functions and update data type annotations in constructor and predict method.
1 parent 9a00ce1 commit 4ca43c6

File tree

6 files changed

+248
-1
lines changed

6 files changed

+248
-1
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ for (let i = 0; i < n; i++) {
135135
| generate | MH, Slice sampling, GMM, GBRBM, HMM, VAE, GAN, NICE, Diffusion |
136136
| smoothing | (Linear weighted / Triangular / Cumulative) Moving average, Exponential average, Moving median, KZ filter, Savitzky Golay filter, Hampel filter, Kalman filter, Particle filter, Lowpass filter, Bessel filter, Butterworth filter, Chebyshev filter, Elliptic filter |
137137
| timeseries prediction | Holt winters, AR, ARMA, SDAR, VAR, Kalman filter, MLP, RNN |
138-
| change point detection | Cumulative sum, k-nearest neighbor, LOF, COF, SST, KLIEP, LSIF, uLSIF, LSDD, HMM, Markov switching |
138+
| change point detection | Cumulative sum, k-nearest neighbor, LOF, COF, SST, KLIEP, LSIF, uLSIF, LSDD, PELT, HMM, Markov switching |
139139
| segmentation | P-Tile, Automatic thresholding, Balanced histogram thresholding, Otsu's method, Sezan, Adaptive thresholding, Bernsen, Niblack, Sauvola, Phansalkar, Split and merge, Statistical Region Merging, Mean shift |
140140
| denoising | NL-means, Hopfield network, RBM, GBRBM |
141141
| edge detection | Roberts cross, Sobel, Prewitt, Laplacian, LoG, Canny, Snakes |

js/model_selector.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,7 @@ const AIMethods = [
532532
{ value: 'lsif', title: 'LSIF' },
533533
{ value: 'ulsif', title: 'uLSIF' },
534534
{ value: 'lsdd', title: 'LSDD' },
535+
{ value: 'pelt', title: 'PELT' },
535536
{ value: 'hmm', title: 'HMM' },
536537
{ value: 'markov_switching', title: 'Markov Switching' },
537538
{ value: 'change_finder', title: 'Change Finder' },

js/view/pelt.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import PELT from '../../lib/model/pelt.js'
2+
import Controller from '../controller.js'
3+
4+
export default function (platform) {
5+
platform.setting.ml.usage = 'Click and add data point. Then, click "Calculate".'
6+
platform.setting.ml.reference = {
7+
author: 'R. Killick, P. Fearnhead, I. A. Eckley',
8+
title: 'Optimal detection of changepoints with a linear computational cost',
9+
year: 2024,
10+
}
11+
const controller = new Controller(platform)
12+
const calcSST = function () {
13+
const model = new PELT(penalty.value, cost.value)
14+
const pred = model.predict(platform.trainInput)
15+
platform.trainResult = pred.map(v => +v)
16+
platform.threshold = 0.5
17+
}
18+
19+
const penalty = controller.input.number({ label: ' penalty ', min: 0, max: 100, step: 0.1, value: 0.1 })
20+
const cost = controller.select(['l2', 'l1', 'rbf'])
21+
controller.input.button('Calculate').on('click', calcSST)
22+
}

lib/model/pelt.js

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* Pruned Exact Linear Time
3+
*/
4+
export default class PELT {
5+
// Optimal detection of changepoints with a linear computational cost
6+
// https://arxiv.org/pdf/1101.1438
7+
// https://github.com/deepcharles/ruptures
8+
/**
9+
* @param {number} beta Penalty constant
10+
* @param {"rbf" | "l1" | "l2" | function (number[][], number, number): number} cost Measure of data
11+
*/
12+
constructor(beta, cost = 'rbf') {
13+
this._jump = 1
14+
this._min_size = 1
15+
this._penalty = beta
16+
this._k = beta
17+
18+
if (typeof cost === 'function') {
19+
this._cost = cost
20+
} else if (cost === 'rbf') {
21+
this._cost = (() => {
22+
const k = []
23+
return (data, s, e) => {
24+
if (k.length === 0) {
25+
for (let i = 0; i < data.length; i++) {
26+
k[i] = []
27+
for (let j = 0; j < i; j++) {
28+
k[i][j] = Math.exp(-data[i].reduce((s, v, t) => s + (v - data[j][t]) ** 2, 0))
29+
}
30+
}
31+
}
32+
33+
let c = 0
34+
for (let i = s; i < e; i++) {
35+
for (let j = s; j < i; j++) {
36+
c -= k[i][j] * 2
37+
}
38+
}
39+
return c / (e - s)
40+
}
41+
})()
42+
} else if (cost === 'l1') {
43+
this._min_size = 2
44+
this._cost = (data, s, e) => {
45+
const d = data.slice(s, e)
46+
const dim = d[0].length
47+
let c = 0
48+
for (let j = 0; j < dim; j++) {
49+
const dj = d.map(d => d[j])
50+
dj.sort((a, b) => a - b)
51+
const median =
52+
dj.length % 2 === 0 ? (dj[dj.length / 2] + dj[dj.length / 2 - 1]) / 2 : dj[(dj.length - 1) / 2]
53+
for (let i = 0; i < e - s; i++) {
54+
c += Math.abs(d[i][j] - median)
55+
}
56+
}
57+
return c
58+
}
59+
} else if (cost === 'l2') {
60+
this._cost = (data, s, e) => {
61+
const d = data.slice(s, e)
62+
const dim = d[0].length
63+
let c = 0
64+
for (let j = 0; j < dim; j++) {
65+
const mean = d.reduce((s, v) => s + v[j], 0) / d.length
66+
for (let i = 0; i < e - s; i++) {
67+
c += (d[i][j] - mean) ** 2
68+
}
69+
}
70+
return c
71+
}
72+
}
73+
}
74+
75+
/**
76+
* Returns changepoint or not.
77+
* @param {number[][]} datas Training data
78+
* @returns {boolean[]} Predicted values
79+
*/
80+
predict(datas) {
81+
const n = datas.length
82+
83+
const partitions = [[0]]
84+
let admissible = []
85+
86+
const idx = []
87+
for (let i = 0; i < n; i += this._jump) {
88+
if (i >= this._min_size) {
89+
idx.push(i)
90+
}
91+
}
92+
idx.push(n)
93+
94+
for (const backPoint of idx) {
95+
const admPoint = Math.floor((backPoint - this._min_size) / this._jump) * this._jump
96+
admissible.push(admPoint)
97+
98+
let bestPartition = null
99+
let bestCost = Infinity
100+
const subpart = []
101+
for (const t of admissible) {
102+
if (!partitions[t]) {
103+
subpart.push(null)
104+
continue
105+
}
106+
const part = partitions[t].concat()
107+
part[backPoint] = this._cost(datas, t, backPoint) + this._penalty
108+
const cost = part.reduce((s, v) => s + v, 0)
109+
if (cost < bestCost) {
110+
bestPartition = part
111+
bestCost = cost
112+
}
113+
subpart.push(part)
114+
}
115+
116+
partitions[backPoint] = bestPartition
117+
admissible = admissible.filter((_, i) => {
118+
return subpart[i] && subpart[i].reduce((s, v) => s + v, 0) <= bestCost + this._k
119+
})
120+
}
121+
122+
this._partitions = partitions[n]
123+
const pred = Array(datas.length).fill(false)
124+
this._partitions.forEach((_, i) => {
125+
if (i > 0 && i < datas.length) {
126+
pred[i] = true
127+
}
128+
})
129+
return pred
130+
}
131+
}

tests/gui/view/pelt.test.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { getPage } from '../helper/browser'
2+
3+
describe('change point detection', () => {
4+
/** @type {Awaited<ReturnType<getPage>>} */
5+
let page
6+
beforeEach(async () => {
7+
page = await getPage()
8+
const taskSelectBox = page.locator('#ml_selector dl:first-child dd:nth-child(5) select')
9+
await taskSelectBox.selectOption('CP')
10+
const modelSelectBox = page.locator('#ml_selector .model_selection #mlDisp')
11+
await modelSelectBox.selectOption('pelt')
12+
})
13+
14+
afterEach(async () => {
15+
await page?.close()
16+
})
17+
18+
test('initialize', async () => {
19+
const methodMenu = page.locator('#ml_selector #method_menu')
20+
const buttons = methodMenu.locator('.buttons')
21+
22+
const penalty = buttons.locator('input:nth-of-type(1)')
23+
await expect(penalty.inputValue()).resolves.toBe('0.1')
24+
const cost = buttons.locator('select')
25+
await expect(cost.inputValue()).resolves.toBe('l2')
26+
})
27+
28+
test('learn', async () => {
29+
const methodMenu = page.locator('#ml_selector #method_menu')
30+
const buttons = methodMenu.locator('.buttons')
31+
32+
const calcButton = buttons.locator('input[value=Calculate]')
33+
await calcButton.dispatchEvent('click')
34+
35+
const svg = page.locator('#plot-area svg')
36+
const lines = await svg.locator('.tile-render line')
37+
await expect(lines.count()).resolves.toBeGreaterThan(0)
38+
})
39+
})

tests/lib/model/pelt.test.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { jest, test } from '@jest/globals'
2+
jest.retryTimes(3)
3+
4+
import Matrix from '../../../lib/util/matrix.js'
5+
import PELT from '../../../lib/model/pelt.js'
6+
7+
describe('change point detection', () => {
8+
test.each([undefined, 'rbf'])('cost %p', cost => {
9+
const model = new PELT(0, cost)
10+
const n = 50
11+
const x = Matrix.concat(
12+
Matrix.concat(Matrix.random(n, 1, 0, 1), Matrix.random(n, 1, 3, 4)),
13+
Matrix.random(n, 1, 6, 7)
14+
).toArray()
15+
const p = model.predict(x)
16+
17+
for (let i = 0; i < p.length; i++) {
18+
expect(p[i]).toBe(i === 50 || i === 100)
19+
}
20+
})
21+
22+
test.each([
23+
'l1',
24+
'l2',
25+
(d, s, e) => {
26+
const mean = Array.from(d[0], (_, i) => d.slice(s, e).reduce((s, v) => s + v[i], 0) / (e - s))
27+
return d.slice(s, e).reduce((s, r) => s + r.reduce((t, v, i) => t + Math.abs(v - mean[i]), 0), 0)
28+
},
29+
])('cost %p', cost => {
30+
const model = new PELT(1.0, cost)
31+
const n = 50
32+
const x = Matrix.concat(
33+
Matrix.concat(Matrix.random(n, 1, 0, 1), Matrix.random(n, 1, 5, 6)),
34+
Matrix.random(n, 1, 10, 11)
35+
).toArray()
36+
const p = model.predict(x)
37+
38+
const range = 5
39+
let c = 0
40+
let o = 0
41+
for (let i = 0; i < p.length; i++) {
42+
if (i > n - range && (i % n < range || i % n >= n - range)) {
43+
if (p[i]) {
44+
c++
45+
}
46+
}
47+
if (p[i]) {
48+
o++
49+
}
50+
}
51+
expect(c).toBeGreaterThanOrEqual(2)
52+
expect(o / x.length).toBeLessThan(0.05)
53+
})
54+
})

0 commit comments

Comments
 (0)