-
Notifications
You must be signed in to change notification settings - Fork 2
/
blog.Rmd
626 lines (512 loc) · 30.5 KB
/
blog.Rmd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
---
title: "Blog"
author: "Francisco Bischoff"
date: "on `r format(Sys.time(), '%B %d, %Y')`"
# output:
# workflowr::wflow_html:
# number_sections: true
# fig_caption: yes
# code_folding: none
# toc: no
bibliography: ../papers/references.bib
link-citations: true
csl: ../thesis/csl/ama.csl
css: style.css
editor_options:
chunk_output_type: inline
---
```{r setup, include=FALSE}
knitr::opts_chunk$set(
echo = FALSE, fig.align = "center", dev = "svg", autodep = TRUE,
fig.height = 5, fig.width = 10,
tidy = "styler",
tidy.opts = list(strict = TRUE)
)
library(here)
library(ggplot2)
library(glue)
library(tibble)
# library(visNetwork)
# library(kableExtra)
# library(targets)
knitr::opts_knit$set(root.dir = here("docs"), base.dir = here("docs"))
.rmdenvir <- environment()
.refctr <- c(`_` = 0)
ref <- function(use_name) {
require(stringr)
if (!exists(".refctr")) .refctr <- c(`_` = 0)
if (any(names(.refctr) == use_name)) {
return(.refctr[use_name])
}
type <- str_split(use_name, ":")[[1]][1]
n_obj <- sum(str_detect(names(.refctr), type))
use_num <- n_obj + 1
newrefctr <- c(.refctr, use_num)
names(newrefctr)[length(.refctr) + 1] <- use_name
assign(".refctr", newrefctr, envir = .rmdenvir)
return(use_num)
}
```
# Purpose
This blog is a continuation of the thesis Report [link](report.html). The reader must follow this
page from top to bottom.
## 2021, August Update #1
The first workflow presented in July has changed. Its purpose was to define a "big picture" of the
process.
The main changes introduced were:
### The Data
Initially, the data had been carried along the workflow, being copied at each new step. That
obviously is not the way to handle it. Thus, the raw data is stored only on the "dataset" object and
reused where needed.
Now, any modification to the raw data will create a new object, for example, "ds_filtered," where
the SQI filter is applied to the data.
### Streaming paradigm
The goal of this work is to operate with streaming data. Thus, the Matrix Profile computation
algorithm has been rewritten to handle receiving data in chunks. The algorithm can simulate one
observation at a time or a batch of observations (for efficiency). The result will always be as if
one observation had been received individually by the model.
To avoid unnecessary recomputations for this analysis phase, the companion statistics needed by the
model are pre-computed and fed alongside the data the algorithm needs to process. The
pre-computation also allows experimenting with parameters during this process.
## 2021, October Update #1
### Regime Change Detection
While implementing the streaming-like pipeline, some declarations must be made. In 2017, the FLUSS
(Fast Low-cost Unipotent Semantic Segmentation) and the FLOSS (Fast Low-cost Online Semantic
Segmentation) algorithms were introduced by Gharghabi _et al._ [@gharghabi2017]. In 2018, the same
group published their findings using multi-dimensional time series [@gharghabi2018] using the same
algorithms.
Claims about the algorithm:
- **Domain Agnosticism:** the algorithm makes no assumptions about the data as opposed to most
available algorithms to date.
- **Streaming:** the algorithm can provide real-time information.
- **Real-World Data Suitability:** the objective is not to _explain_ all the data. Therefore, areas
marked as "don't know" areas are acceptable.
- **FLOSS is not:** a change point detection algorithm [@aminikhanghahi2016]. The interest here is
changes in the shapes of a sequence of measurements.
Briefly describing the regime detection algorithm, which can be explored in the original paper [@gharghabi2018], it is based on the assumption that between two regimes, the most similar shape (its nearest neighbor) is located on "the same side". This information is obtained from the Matrix Profile computation. More precisely, using only the Profile Index.
Before talking about the Matrix Profile computation, some findings deserve to be mentioned:
In chapter 3.5 in the original paper, the authors of FLOSS wisely introduce the **temporal
constraint**. Nevertheless, some details are not mentioned. 1) As this algorithm only needs the
Profile Index, should we use the already computed Indexes or recompute the Matrix Profile using this
constraint (i.e., the constraint is on the Profile Index or the FLOSS algorithm?). That is not an
issue about the algorithm but a choice we need to make beforehand. One option is to apply the
constraint on the Profile Index, and we need to have this parameter set from the start. The second
option is to have the FLOSS algorithm not account for the indexes beyond the constraint, keeping the
original Profile Index. 2) The authors declare the correction curve typically used on FLUSS and
FLOSS as "simply a uniform distribution", but this is not an accurate statement. Empirically, there
is a helpful pattern to know about the distribution when using **temporal constraints** (at least
from the start, in the Matrix Profile stage). At first glance, we see that the distribution
resembles the skewed distribution used in FLOSS but is shorter, while the $constraint \ge
MatrixProfileSize/2$. For lower constraints, the maximum value of this distribution is equal to
$MatrixProfileSize/2$ between the indexes $constraint$ to the index $MatrixProfileSize - (constraint
\times 0.9)$. This is shown in Fig. `r ref("fig:distributions")`. That is important because the
output of the FLOSS algorithm should be normalized and constrained between 0 and 1, which allows us
to compare different trials using different parameters in the process. Finally, the last datapoints
are **not** irrelevant, opposed to what was stated by the authors, since an _Online_ algorithm needs
to return an answer as soon as the application domain requires. That is very much relevant to this
work's field, as, for example, for asystole detection, we have a window of 4 seconds to fire the
alarm. If the time constraint is 10 seconds, this would mean (by the original article) that the last
10 seconds of the incoming data would not be sufficient to detect the regime change.
As for the first point mentioned above, it seems more appropriate to set the temporal constraint in
the Matrix Profile algorithm, and indeed this is what the original paper did. That reduces the
computation time of the online Matrix Profile, and any post-processing done afterward will inherit
this constraint. The distribution for correcting the FLOSS algorithm is also simpler. On the other
hand, it is possible to apply the time constraint in the FLOSS algorithm, leaving the online Matrix
Profile in its original form. See Fig. `r ref("fig:floss_dist")`. The theoretical distribution
changes significantly according to the constraint value. The upside of this approach, at least
during the prospective phase, is to allow us to decide the time constraint value later in the
pipeline, avoiding the recomputation of the Matrix Profile. The results on detecting regime changes
are very similar to the first approach. See Fig. `r ref("fig:cac_regimes")`.
Concerning the second point mentioned above, the solution for evaluating the effect of using time
constraints in this work's setting was to generate the ideal distribution using the constrained
parameters beforehand. That gives us enough data to evaluate a regime change accurately utilizing a
minimum of $2 \times WindowSize$ datapoints. The best index is still to be determined, and current
tests are using 3 seconds limit.
```{r dist data, message=FALSE, warning=FALSE, cache=TRUE, include=FALSE}
source(here("scripts", "common", "compute_floss.R"))
get_dist <- function(mp_const = 1250, floss_const = 0) {
set.seed(2021)
iac <- list()
pro_size <- 5000
mp_time_constraint <- mp_const
floss_time_constraint <- floss_const
for (i in 1:5) { # DEBUG
# for (i in 1:500) { # RELEASE
iac[[i]] <- get_asym(pro_size, mp_time_constraint, floss_time_constraint)
}
aic_avg <- rowMeans(as.data.frame(iac))
data.frame(index = 1:5000, counts = aic_avg)
}
data_5000 <- get_dist(5000)
data_4250 <- get_dist(4250)
data_2500 <- get_dist(2500)
data_1250 <- get_dist(1250)
floss_data_5000 <- get_dist(0, 5000)
floss_data_4250 <- get_dist(0, 4250)
floss_data_2500 <- get_dist(0, 2500)
floss_data_1250 <- get_dist(0, 1250)
```
```{r distributions, echo=FALSE, fig.cap=paste("Figure", ref("fig:distributions"), " - 1D-IAC distributions for earlier temporal constraint (on Matrix Profile)"), message=FALSE, warning=FALSE}
floss_dist <- ggplot(data_5000, aes(index, counts)) +
geom_line(size = 0.1) +
ggtitle("a) No constraint") +
theme_grey(base_size = 7)
floss_4250 <- ggplot(data_4250, aes(index, counts)) +
geom_line(size = 0.1) +
ggtitle("b) Constraint of 4250") +
theme_grey(base_size = 7)
floss_2500 <- ggplot(data_2500, aes(index, counts)) +
geom_line(size = 0.1) +
annotate("segment", y = 0, yend = max(data_2500$counts), x = 2500, xend = 2500, linetype = 2, size = 0.1) +
annotate("text", x = 2500 - 80, y = 40, label = "start", color = "black", size = 2, angle = 90, hjust = 0) +
annotate("segment", y = 0, yend = max(data_2500$counts), x = 5000 - 2500 * 0.9, xend = 5000 - 2500 * 0.9, linetype = 2, size = 0.1) +
annotate("text", x = 5000 - 2500 * 0.9 - 80, y = 40, label = "end", color = "black", size = 2, angle = 90, hjust = 0) +
ggtitle("c) Constraint of 2500") +
theme_grey(base_size = 7)
floss_1250 <- ggplot(data_1250, aes(index, counts)) +
geom_line(size = 0.1) +
annotate("segment", y = 0, yend = max(data_1250$counts), x = 1250, xend = 1250, linetype = 2, size = 0.1) +
annotate("text", x = 1250 - 80, y = 40, label = "start", color = "black", size = 2, angle = 90, hjust = 0) +
annotate("segment", y = 0, yend = max(data_1250$counts), x = 5000 - 1250 * 0.9, xend = 5000 - 1250 * 0.9, linetype = 2, size = 0.1) +
annotate("text", x = 5000 - 1250 * 0.9 - 80, y = 40, label = "end", color = "black", size = 2, angle = 90, hjust = 0) +
ggtitle("d) Constraint of 1250") +
theme_grey(base_size = 7)
gg <- gridExtra::grid.arrange(floss_dist, floss_4250, floss_2500, floss_1250,
nrow = 2, newpage = TRUE,
bottom = grid::textGrob(paste("The plot a) shows the distribution used for the arc count correction when there is no time constraint.", "\n", "b) Shows a constraint of 3/4 of the total. c) 1/2 of the total. d) 1/4 of the total; here we see clearly the flat line.", "\n", "The dashed line marks the start and the end of the uniform zone."), just = "center", gp = grid::gpar(fontsize = 7))
)
```
```{r floss_dist, echo=FALSE, fig.cap=paste("Figure", ref("fig:floss_dist"), " - 1D-IAC distributions for later temporal constraint (on FLOSS)"), message=FALSE, warning=FALSE}
floss_dist <- ggplot(floss_data_5000, aes(index, counts)) +
geom_line(size = 0.1) +
ggtitle("a) No constraint") +
theme_grey(base_size = 7)
floss_4250 <- ggplot(floss_data_4250, aes(index, counts)) +
geom_line(size = 0.1) +
ggtitle("b) Constraint of 4250") +
theme_grey(base_size = 7)
floss_2500 <- ggplot(floss_data_2500, aes(index, counts)) +
geom_line(size = 0.1) +
annotate("segment", y = 0, yend = max(data_2500$counts), x = 2500, xend = 2500, linetype = 3, size = 0.1) +
annotate("text", x = 2500 - 80, y = 40, label = "start", color = "black", size = 2, angle = 90, hjust = 0) +
annotate("segment", y = 0, yend = max(data_2500$counts), x = 5000 - 2500 * 0.9, xend = 5000 - 2500 * 0.9, linetype = 3, size = 0.1) +
annotate("text", x = 5000 - 2500 * 0.9 - 80, y = 40, label = "end", color = "black", size = 2, angle = 90, hjust = 0) +
ggtitle("c) Constraint of 2500") +
theme_grey(base_size = 7)
floss_1250 <- ggplot(floss_data_1250, aes(index, counts)) +
geom_line(size = 0.1) +
annotate("segment", y = 0, yend = max(data_1250$counts), x = 1250, xend = 1250, linetype = 3, size = 0.1) +
annotate("text", x = 1250 - 80, y = 40, label = "start", color = "black", size = 2, angle = 90, hjust = 0) +
annotate("segment", y = 0, yend = max(data_1250$counts), x = 5000 - 1250 * 0.9, xend = 5000 - 1250 * 0.9, linetype = 3, size = 0.1) +
annotate("text", x = 5000 - 1250 * 0.9 - 80, y = 40, label = "end", color = "black", size = 2, angle = 90, hjust = 0) +
ggtitle("d) Constraint of 1250") +
theme_grey(base_size = 7)
gg <- gridExtra::grid.arrange(floss_dist, floss_4250, floss_2500, floss_1250,
nrow = 2, newpage = TRUE,
bottom = grid::textGrob(paste("The plot a) shows the distribution used for the arc count correction when there is no time constraint.", "\n", "b) Shows a constraint of 3/4 of the total. c) 1/2 of the total. d) 1/4 of the total; here we see clearly the flat line.", "\n", "The dotted line marks the start and the end of the uniform zone if using the constraint in the Matrix Profile."), just = "center", gp = grid::gpar(fontsize = 7))
)
```
```{r output_data, cache=TRUE, include=FALSE}
output <- readRDS(here("output/work_output.rds"))
mp_data <- output$mp_constraint[[1]]$II[[76]]
mp_data <- tibble(time = seq_along(mp_data$cac), cac = mp_data$cac, iac = mp_data$iac, arcs = mp_data$arcs)
floss_data <- output$floss_constraint[[1]]$II[[76]]
floss_data <- tibble(time = seq_along(floss_data$cac), cac = floss_data$cac, iac = floss_data$iac, arcs = floss_data$arcs)
# rm(output)
```
```{r constraints, echo=FALSE, fig.cap=paste("Figure", ref("fig:cac_regimes"), " - CAC and Regime detection using early and later IAC"), message=FALSE, warning=FALSE}
landmark <- 5000 - 3 * 250
mp_cac_landmark <- mp_data$cac[landmark]
floss_cac_landmark <- floss_data$cac[landmark]
mp_constraint <- ggplot(mp_data, aes(time, cac)) +
geom_line(size = 0.1) +
ylim(0, 1) +
ylab("CAC") +
xlab("") +
annotate("segment", y = 0, yend = 1, x = landmark, xend = landmark, linetype = 1, size = 0.1, color = "red") +
annotate("text", x = landmark, y = mp_cac_landmark, label = sprintf("%.2f", mp_cac_landmark), color = "red", size = 2, vjust = 0, hjust = -0.2) +
ggtitle("a) Constraint of 1250 on Matrix Profile") +
theme_grey(base_size = 7)
floss_constraint <- ggplot(floss_data, aes(time, cac)) +
geom_line(size = 0.1) +
ylim(0, 1) +
ylab("") +
xlab("") +
annotate("segment", y = 0, yend = 1, x = landmark, xend = landmark, linetype = 1, size = 0.1, color = "red") +
annotate("text", x = landmark, y = floss_cac_landmark, label = sprintf("%.2f", floss_cac_landmark), color = "red", size = 2, vjust = 0, hjust = -0.2) +
ggtitle("b) Constraint of 1250 on FLOSS") +
theme_grey(base_size = 7)
mp_arcs <- ggplot(mp_data, aes(time, arcs)) +
geom_line(size = 0.1) +
geom_line(aes(time, iac), size = 0.1, color = "red") +
ylab("Arcs and IAC") +
annotate("segment", y = 0, yend = 900, x = 1250, xend = 1250, linetype = 3, size = 0.1) +
annotate("segment", y = 0, yend = 900, x = 5000 - 1250 * 0.9, xend = 5000 - 1250 * 0.9, linetype = 3, size = 0.1) +
theme_grey(base_size = 7)
floss_arcs <- ggplot(floss_data, aes(time, arcs)) +
geom_line(size = 0.1) +
geom_line(aes(time, iac), size = 0.1, color = "red") +
ylab("") +
annotate("segment", y = 0, yend = 800, x = 1250, xend = 1250, linetype = 3, size = 0.1) +
annotate("segment", y = 0, yend = 800, x = 5000 - 1250 * 0.9, xend = 5000 - 1250 * 0.9, linetype = 3, size = 0.1) +
theme_grey(base_size = 7)
gg <- gridExtra::grid.arrange(mp_constraint, floss_constraint, mp_arcs, floss_arcs,
nrow = 2, newpage = TRUE,
bottom = grid::textGrob(paste(
"The plots on a) show above the Corrected Arc Count (CAC) and below the raw arc counts (black) and the ideal arc count (IAC) (red) using the temporal\n constraint earlier on the Matrix Profile.",
"The plots on b) show above the Corrected Arc Count (CAC) and below the raw arc counts (black) and\nthe ideal arc count (IAC) (red) using the temporal constraint later on the FLOSS algorithm.",
"The red vertical line marks the point\nwhere the current algorithm watches for regime changes."
),
just = "center", gp = grid::gpar(fontsize = 7)
)
)
```
### The Matrix Profile Algorithm
Since the first Matrix Profile computation algorithm, the STAMP [@yeh2016], several improvements on
the algorithm were made [@zhu2016; @zhu2018]. Still, the ability to keep a growing Matrix Profile
(i.e., _Online_) relies on the STAMP algorithm. If the problem allows collecting several data points
(chunks), STOMP [@zhu2016] can speed up the computation. Curiously, the main bottleneck of all these
algorithms is the FFT (Fast Fourier Transform) algorithm that is the core of the MASS algorithm
published by Mueen _et al._ [@mueen2010] in 2010 and later in 2015, having its code released on
Professor Mueen's webpage [@mass2015]. The FFT libraries available are highly optimized and CPU (or
GPU) dependent what makes it at the same time fast but brittle and not suitable for MCU's
(Microcontroller Unit), for example. More interestingly yet, is the fact that several published
works using Matrix Profile, MPdist [@gharghabi2018a], for instance, uses an unpublished algorithm
called 'MPX' that computes the Matrix Profile using cross-correlation methods ending up faster and
is easily portable.
This work contributes to extending the MPX algorithm to allow the _Online_ computation of the Matrix
Profile. More precisely, we are interested in the Right Matrix Profile, whose updated indexes refer
only to the last incoming datapoint since we are looking for future regime changes, not backward.
This one-directional algorithm is already described in the FLOSS paper [@gharghabi2018].
Another contribution of this work is an unexplored constraint that we could apply on building the
Matrix Profile that we will call _Similarity Threshold_ (ST). The original work outputs the
similarity values in Euclidean Distance (ED) values, while MPX naturally outputs the values in
Pearson's correlation (CC) coefficients. Both ED and CC are interchangeable using the equation
$\eqref{edcc}$. However, we may argue that it is easier to compare values that do not depend on the
$WindowSize$ during an exploratory phase. MPX happens to naturally return values in CC, saving a few
more computation time.
$$
CC = 1 - \frac{ED}{(2 \times WindowSize)} \tag{1} \label{edcc}
$$
The ST is an interesting factor that we can use, especially when detecting pattern changes during
time. The FLUSS/FLOSS algorithms rely on counting references between indexes in the time series. ST
can help remove "noise" from these references since only similar patterns above a certain threshold
are referenced, and changes have more impact on these counts. More information and visual content on
ST will be provided later. The best ST threshold is still to be determined.
## 2022, January Update #1
These last months were dedicated to several important things:
1. Restructuring the roadmap
2. Refining the main pipeline
3. Preparing for modeling and parameter tuning
4. Feasibility trial
5. And others
### Refining the main pipeline
That can also be thought of as "rethinking" the pipeline. What also leads to the roadmap
restructuration.
It is essential not only to write a pipeline that can "autoplot" itself for fine-grain inspection
but also to design a high-level graph that can explain it "in a glance". This exercise was helpful
both ways: telling the story in a short version also reveals missing things and misleading paths
that are not so obvious when thinking "low-level".
### Preparing for modeling and parameter tuning
Although this work has its purpose of being finally deployed on small hardware, this prospective
phase will need several hours of computing, tuning, evaluation, and validation of all findings.
Thus it was necessary to revisit the frameworks we are used to working on R: `caret` and the newest
`tidymodels` collection. For sure, there are other frameworks and opinions [@Thompson2020].
Notwithstanding, this project will follow the `tidymodels` road. Two significant arguments 1)
constantly improving and constantly being re-checked for bugs; 2) allows to plug in a custom
modeling algorithm that, in this case, will be the one needed for developing this work.
### Feasibility trial
A side-project called "false.alarm.io" has been derived from this work (an unfortunate mix of
"false.alarm" and "PlatformIO" [@PlatformIO], the IDE chosen to interface the panoply of embedded
systems we can experiment). The current results of this side-project are very enlightening and show
that the final algorithm can indeed be used in small hardware. Further data will be available in
the future.
### And others
After this "step back" to look forward, it was time to define how the regime change algorithm would
integrate with the actual decision of triggering or not the alarm. Some hypotheses were thought out:
(1) clustering similar patterns, (2) anomaly detection, (3) classification, and (4) forecasting.
Among these methods, it was thought to avoid exceeding processor capacity, an initial set of
shapelets [@Rakthanmanon2013] can be sufficient to rule in or out the `TRUE`/`FALSE` challenge.
Depending on the accuracy of this approach and the resources available, another method can be
introduced for both (1) improving the "negative"[^1] samples and (2) learning more shapelets to
improve the `TRUE`/`FALSE` alarm discrimination.
> Minor update, but also important concerning the _FAIR_ principle "Interoperability": the dataset
stored publicly on Zenodo [@bischoff2021] was converted from `.mat` to `.csv`.
[^1]: The term "negative" does not imply that the patient has a "normal" ECG. It means that the
"negative" section is not a life-threatening condition that needs to trigger an alarm.
## 2022, March Update #1
These last couple of months were dedicated to:
1. Writing the new [report](report.html)
2. Presenting the report to the jury
3. Getting back to the filters
## Back to the filters
```{r btof, echo=FALSE, out.width="40%", fig.align = "left"}
knitr::include_graphics("figure/back_to_filters.svg")
```
Back at the beginning of this thesis, we have talked about using some kind of filtering to be used
during the signal acquisition in order to not process artifacts and disconnected cables, for
example. This was described in the section "Preparing the data" on the [report](report.html).
The initial approach to select the "complexity" formula [@Batista2014] was based on a few
experiments that I felt deserved a more scientific approach.
TLDR; "complexity" is still the best choice, but here are the steps for choosing it.
Several signal quality indexes (SQI) were used to assess the ECG signal's noise in literature. A brief
list of them:
1. **Activity**: is just the variance of the signal
$$
Activity = Var(data)
$$
2. **Mobility**: derives from the Activity. The squared root of the ratio of the variance of the first
derivative of the signal to the variance of the original signal
$$
Mobility = \sqrt{\frac{Var(\nabla{data})}{Var(data)}}
$$
3. **Complexity**[^2]: Ratio of the Mobility of the first derivative of the signal to the Mobility
of the original signal
$$
Complexity = \frac{\sqrt{\frac{Var(\nabla^2{data})}{Var(\nabla{data})}}}{\sqrt{\frac{Var(\nabla{data})}{Var(data)}}}
\rightarrow \frac{\sqrt{Var(\nabla^2{data}) \cdot Var(data)}}{Var(\nabla{data})}
$$
4. **complexity**: This is the SQI defined by Batista, *et al.* [@Batista2014], and we will write it
as complexity (lower case) to not confuse with the Complexity from point 3. This was built by the
author around a simple intuition: more complex signals, when "stretched", are longer than simple
signals. Fig. `r ref("fig:cplxty")` shows this intuition. The formula is just the "sum of squares
of the differences".
```{r cplxty, echo=FALSE, out.width="80%", fig.cap=paste("Figure", ref("fig:cplxty"), " - From Batista _et al._ shows on the left side, the signal, and on the right side, the signal \"stretched\".")}
knitr::include_graphics("figure/complx.svg")
```
$$
complexity = \sqrt{\sum{(\nabla data)^2}}
$$
5. **Kurtosis**: Measure of the Gaussianity of a distribution. As ECG signals are hyper-Gaussian,
higher kurtosis values are associated with lower quality in the ECG. One of its simples formula
(which is quite complicated) is:
$$
Kurtosis = \frac{1}{n}\sum_{i=1}^{n}{z_i^4} - 3
$$
6. **Karhunen-Loeve transform (KLT)**: KLT is a transformation that reduces a large set of variables
down to a smaller set. The smaller set of variables separates the information of the different
sources (ECG and Noise). In this way, noise can be estimated, and the SNR calculated.
7. **First-Difference histogram**: The baseline is defined as the most common sample value during
R-R periods. The sample value corresponding to the histogram peak (mode) was declared the
baseline, and the difference between consecutive baselines gives the baseline shift from beat to
beat. Noise is estimated from the first-difference histogram of R-R intervals. Noise
contribution is one minus the frequency of occurrence of first differences with values around
zero divided by the number of samples in the R-R interval.
8. **Turns counts**: Counting of the number of local minimums with amplitude higher than a threshold
(e.g. 0.1mV). This SQI is actually robust to noise, so it is not suitable for evaluating noise.
[^2]: This is not the same "complexity" from Batista, *et al.* [@Batista2014], but from Del Rio, *et
al.* [@DelRio2011].
This is not an exhaustive list. Also, we want a simple SQI as we must use the smallest processor
and memory possible. For this reason, we will do the experiments with the following SQI on that list:
Activity, Mobility, Complexity, and complexity. In addition, we will experiment with another
simple index, the signal's amplitude.
Let's take the first 12 files from Physionet's Challenge dataset, in alphabetic order, from `a103l` to `a165l`.
By manual inspection, we have the following files as _negative_ for noise #4, #7, #8, #10, #11, #12.
And for _positive_ for noise the following #1, #2, #3, #5, #6, #9.
```{r load_scripts}
source(here("scripts/common/find_all_files.R"), encoding = "UTF-8")
source(here("scripts/common/read_ecg.R"), encoding = "UTF-8")
source(here("scripts/common/get_set_attributes.R"), encoding = "UTF-8")
source(here("scripts/common/compute_filters.R"), encoding = "UTF-8")
source(here("scripts/common/sqi.R"), encoding = "UTF-8")
```
```{r read_files_filters, cache = TRUE}
neg <- c(4, 7, 8, 10, 11, 12)
pos <- c(1, 2, 3, 5, 6, 9)
filenames <- find_all_files(classes = "asystole", limit_per_class = 12)
pos_files <- list()
i <- 1
for (file in filenames[pos]) {
pos_files[i] <- read_ecg_csv(file, normalize = FALSE)
i <- i + 1
}
neg_files <- list()
i <- 1
for (file in filenames[neg]) {
neg_files[i] <- read_ecg_csv(file, normalize = FALSE)
i <- i + 1
}
pos_filters <- list()
for (i in seq.int(1, length(pos_files))) {
pos_filters[[i]] <- compute_filters(pos_files[[i]]$II, list(window_size = 250, filter_w_size = 100), get_info(pos_files[[i]]))
pos_filters[[i]]$time <- pos_files[[i]]$time
}
neg_filters <- list()
for (i in seq.int(1, length(neg_files))) {
neg_filters[[i]] <- compute_filters(neg_files[[i]]$II, list(window_size = 250, filter_w_size = 100), get_info(neg_files[[i]]))
neg_filters[[i]]$time <- neg_files[[i]]$time
}
```
Fig. `r ref("fig:filters_samples")` shows a sample of a positive and a negative signal (for noise) and a sample of some SQI.
What we need is an index that has a low value when the signal has low noise, and a high value when there is a noisy signal (that we will not process).
```{r filters_samples, fig.cap = paste("Figure", ref("fig:filters_samples"), " - ECG samples of the positive (for noise) and negative"), warning=FALSE}
lwy <- 0.2
size <- 9
sample_pos <- ggplot(as_tibble(pos_files[[2]])[15500:19000, ], aes(time, II)) +
geom_line(size = lwy) +
ggtitle(glue("ECG with artifacts (file: {get_info(pos_files[[1]])$filename})")) +
theme_grey(base_size = size) +
theme(
axis.title.y = element_blank(),
axis.text.y = element_blank(),
axis.ticks.y = element_blank(),
axis.title.x = element_blank(),
axis.text.x = element_blank(),
axis.ticks.x = element_blank(),
plot.margin = margin(5, 5, 0, 5)
)
sample_filter_pos <- ggplot(as_tibble(pos_filters[[2]])[15500:19000, ], aes(time, complex_norm)) +
geom_line(size = lwy) +
ylim(0, 15) +
ggtitle(glue("Sample SQI")) +
theme_grey(base_size = size) +
theme(
axis.title.y = element_blank(),
axis.text.y = element_blank(),
axis.ticks.y = element_blank(),
plot.margin = margin(-13, 5, 5, 5),
plot.title = element_text(vjust = -8, size = 8)
) +
labs(x = "time (s)")
sample_neg <- ggplot(as_tibble(neg_files[[2]])[1001:4500, ], aes(time, II)) +
geom_line(size = lwy) +
ggtitle(glue("ECG without artifacts (file: {get_info(neg_files[[1]])$filename})")) +
theme_grey(base_size = size) +
theme(
axis.title.y = element_blank(),
axis.text.y = element_blank(),
axis.ticks.y = element_blank(),
axis.title.x = element_blank(),
axis.text.x = element_blank(),
axis.ticks.x = element_blank(),
plot.margin = margin(5, 5, 0, 5)
)
sample_filter_neg <- ggplot(as_tibble(neg_filters[[2]])[1001:4500, ], aes(time, complex_norm)) +
geom_line(size = lwy) +
ylim(0, 15) +
ggtitle(glue("Sample SQI")) +
theme_grey(base_size = size) +
theme(
axis.title.y = element_blank(),
axis.text.y = element_blank(),
axis.ticks.y = element_blank(),
plot.margin = margin(-13, 5, 5, 5),
plot.title = element_text(vjust = -8, size = 8)
) +
labs(x = "time (s)")
gridExtra::grid.arrange(sample_pos, sample_filter_pos, sample_neg, sample_filter_neg,
nrow = 4, newpage = TRUE,
bottom = grid::textGrob(glue("The plot above shows a section of an ECG signal that is suddenly interfered with by a bad lead connection or muscle contractions. \n The plot below is a fairly good ECG signal with distortions caused by atrial fibrillation but no artifacts."), just = "center", gp = grid::gpar(fontsize = 9))
)
```
First we need to specify how to evaluate the performance of the SQIs, without a hard labeled annotation, i.e. only knowing that one series has noise (but we don't know where) and other series doesn't have noise. Another information by quick inspection is that all 12 time series are "clean" from data points 30500 to 32500. This gives us a hint of the starting threshold for what is "negative".
As we can see on Fig. `r ref("fig:filters_samples")`, the SQI fluctuates where there is a clean QRS complex. The threshold must be above this fluctuation.
To be more robust on defining such threshold, instead of finding the maximum value, we will take the quantile 0.9 of those values, and then multiply this value by some constant:
$$
\text{thr} = Q_{SQI}(0.9) \cdot \epsilon, \quad \text{for} \, \epsilon \in \mathbb{R}^+_*
$$
What is expected is that on the negative set, we will have no values above the threshold. And on the positive set, we will have values above, where there are noise.
Another think we must consider is the normalization of the time series and the gain value.
## Further Steps {-}
- Publish results
## References {-}