Skip to content

Commit d5297c1

Browse files
committed
Release 1.3.1: polish, safety, and benchmark updates.
- idx_to_letter bounds check [1, 26] - _is_equal_mindist(std::string) in SAX numerosity reduction - Remove unused HOT-SAX state; fix cosine_dist roxygen - Extend bench_discords.R with RRA and SAX cases - NEWS and version bump summarizing Phases 1-4
1 parent 8f6d9c1 commit d5297c1

11 files changed

Lines changed: 286 additions & 16 deletions

File tree

DESCRIPTION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
Package: jmotif
2-
Version: 1.3.0
2+
Version: 1.3.1
33
Encoding: UTF-8
44
Title: Time Series Analysis Toolkit Based on Symbolic Aggregate Discretization, i.e. SAX
55
Description: Implements time series z-normalization, SAX, HOT-SAX, VSM, SAX-VSM, RePair, and RRA

NEWS.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
# jmotif 1.3.1
2+
3+
* Memory and performance (Phases 1–4):
4+
- Fix memory leaks in RePair grammar (`rule_record` by-value), RRA, and
5+
`cosine_sim` (Valgrind-clean on long runs).
6+
- HOT-SAX: precompute z-normed sliding windows (~1.4× on ecg0606, ~17× on
7+
longer series); identical discord positions and distances.
8+
- SAX sliding window: `_paa2` in-place fractional PAA, `_znorm_slice`,
9+
unified `_sax_via_window` path (~2.3× on bag construction).
10+
- RRA: fused normalized distance, precomputed `w_size` z-norm windows
11+
(~1.3× on ecg0606); identical results and distance-call counts.
12+
* Safety: `idx_to_letter()` validates index range [1, 26].
13+
* Internal: use `_is_equal_mindist(std::string)` in SAX numerosity reduction;
14+
remove unused HOT-SAX state; clarify `cosine_dist()` roxygen.
15+
116
# Version 1.3.0
217
* Cross-implementation alignment with the saxpy (Python) reference and the
318
Matrix Profile / MASS convention:

R/jmotif.R

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,10 @@ min_dist <- function(str1, str2, alphabet_size, compression_ratio = 1) {
7070
}
7171
}
7272

73-
#' Computes the cosine similarity between numeric vectors
73+
#' Computes the cosine distance between numeric vectors (1 minus cosine similarity).
7474
#'
75-
#' @param m the data matrix
76-
#' @return Returns the cosine similarity
75+
#' @param m the data matrix (rows are vectors).
76+
#' @return A \code{dist} object of pairwise cosine distances.
7777
#' @export
7878
#' @examples
7979
#' a <- c(2, 1, 0, 2, 0, 1, 1, 1)

bench_discords.R

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#!/usr/bin/env Rscript
2+
# Benchmark discord discovery — primarily HOT-SAX (Phase 2 z-norm precompute).
3+
#
4+
# Usage:
5+
# Rscript bench_discords.R [label] [out_csv]
6+
#
7+
# Example before/after on poptiplex:
8+
# git checkout 37353dc && R CMD INSTALL --library=$HOME/R/library .
9+
# Rscript inst/site/bench_discords.R pre-phase2
10+
# git checkout master && R CMD INSTALL --library=$HOME/R/library .
11+
# Rscript inst/site/bench_discords.R post-phase2
12+
# Rscript inst/site/bench_discords.R --compare bench_discords_results.csv
13+
14+
suppressPackageStartupMessages(library(jmotif))
15+
16+
args <- commandArgs(trailingOnly = TRUE)
17+
18+
if (length(args) >= 1 && args[[1]] == "--compare") {
19+
csv <- if (length(args) >= 2) args[[2]] else "bench_discords_results.csv"
20+
if (!file.exists(csv)) stop("CSV not found: ", csv)
21+
d <- read.csv(csv, stringsAsFactors = FALSE)
22+
agg <- aggregate(
23+
elapsed_sec ~ label + benchmark,
24+
data = d,
25+
FUN = function(x) c(mean = mean(x), sd = sd(x), min = min(x), max = max(x))
26+
)
27+
flat <- data.frame(
28+
label = agg$label,
29+
benchmark = agg$benchmark,
30+
mean_sec = agg$elapsed_sec[, "mean"],
31+
sd_sec = agg$elapsed_sec[, "sd"],
32+
min_sec = agg$elapsed_sec[, "min"],
33+
max_sec = agg$elapsed_sec[, "max"],
34+
stringsAsFactors = FALSE
35+
)
36+
labels <- unique(flat$label)
37+
if (length(labels) >= 2) {
38+
wide <- reshape(flat[, c("label", "benchmark", "mean_sec")],
39+
idvar = "benchmark", timevar = "label", direction = "wide")
40+
pre <- grep("pre", labels, value = TRUE)[1]
41+
post <- grep("post", labels, value = TRUE)[1]
42+
if (is.na(pre)) pre <- labels[1]
43+
if (is.na(post)) post <- labels[length(labels)]
44+
pre_col <- paste0("mean_sec.", pre)
45+
post_col <- paste0("mean_sec.", post)
46+
if (pre_col %in% names(wide) && post_col %in% names(wide)) {
47+
wide$speedup <- wide[[pre_col]] / wide[[post_col]]
48+
cat("\n=== Speedup (", pre, " / ", post, " = times faster) ===\n", sep = "")
49+
print(wide[, c("benchmark", pre_col, post_col, "speedup")], row.names = FALSE)
50+
}
51+
}
52+
cat("\n=== Summary by label ===\n")
53+
print(flat, row.names = FALSE)
54+
quit(save = "no", status = 0)
55+
}
56+
57+
label <- if (length(args) >= 1) args[[1]] else format(Sys.time(), "%Y%m%d-%H%M")
58+
out_csv <- if (length(args) >= 2) args[[2]] else "bench_discords_results.csv"
59+
60+
benchmark_one <- function(name, expr, n = 5, warmup = 2) {
61+
for (i in seq_len(warmup)) eval(expr, envir = parent.frame())
62+
gc(verbose = FALSE)
63+
64+
times <- numeric(n)
65+
for (i in seq_len(n)) {
66+
gc(verbose = FALSE)
67+
t0 <- proc.time()
68+
eval(expr, envir = parent.frame())
69+
times[i] <- (proc.time() - t0)[["elapsed"]]
70+
}
71+
72+
data.frame(
73+
label = label,
74+
benchmark = name,
75+
n = n,
76+
elapsed_sec = times,
77+
timestamp = format(Sys.time(), "%Y-%m-%d %H:%M:%S"),
78+
stringsAsFactors = FALSE
79+
)
80+
}
81+
82+
x <- ecg0606
83+
x_medium <- rep(ecg0606, 3L)
84+
85+
cat("=== jmotif discord benchmarks ===\n")
86+
cat("label:", label, "\n")
87+
cat("package:", as.character(packageVersion("jmotif")), "\n")
88+
cat("ecg0606 length:", length(x), "\n")
89+
cat("medium series length:", length(x_medium), "\n\n")
90+
91+
cases <- list(
92+
list(name = "hotsax_ecg0606_4disc",
93+
expr = quote(find_discords_hotsax(x, 100, 4, 4, 0.01, 4)),
94+
n = 10, warmup = 3),
95+
list(name = "hotsax_ecg0606_5disc",
96+
expr = quote(find_discords_hotsax(x, 100, 4, 4, 0.01, 5)),
97+
n = 10, warmup = 3),
98+
list(name = "hotsax_medium_4disc",
99+
expr = quote(find_discords_hotsax(x_medium, 100, 4, 4, 0.01, 4)),
100+
n = 5, warmup = 2),
101+
list(name = "brute_ecg0606_4disc",
102+
expr = quote(find_discords_brute_force(x, 100, 4, 0.01)),
103+
n = 5, warmup = 2),
104+
list(name = "wordbag_ecg0606",
105+
expr = quote(series_to_wordbag(x, 60, 6, 6, "exact", 0.01)),
106+
n = 10, warmup = 3),
107+
list(name = "rra_ecg0606_4disc",
108+
expr = quote(find_discords_rra(x, 100, 4, 4, "none", 0.01, 4)),
109+
n = 5, warmup = 2),
110+
list(name = "sax_medium_w160",
111+
expr = quote(sax_via_window(x_medium, 160, 4, 4, "none", 0.01)),
112+
n = 5, warmup = 2)
113+
)
114+
115+
results <- NULL
116+
for (case in cases) {
117+
cat("Running", case$name, "...\n")
118+
flush.console()
119+
chunk <- benchmark_one(case$name, case$expr, n = case$n, warmup = case$warmup)
120+
print(aggregate(elapsed_sec ~ 1, data = chunk, FUN = mean))
121+
results <- if (is.null(results)) chunk else rbind(results, chunk)
122+
}
123+
124+
agg <- aggregate(elapsed_sec ~ benchmark, data = results, FUN = function(v) {
125+
c(mean = mean(v), sd = sd(v), min = min(v), max = max(v))
126+
})
127+
summary_df <- data.frame(
128+
benchmark = agg$benchmark,
129+
mean_sec = round(agg$elapsed_sec[, "mean"], 4),
130+
sd_sec = round(agg$elapsed_sec[, "sd"], 4),
131+
min_sec = round(agg$elapsed_sec[, "min"], 4),
132+
max_sec = round(agg$elapsed_sec[, "max"], 4)
133+
)
134+
135+
cat("=== Run summary (label =", label, ") ===\n")
136+
print(summary_df, row.names = FALSE)
137+
138+
if (file.exists(out_csv)) {
139+
existing <- read.csv(out_csv, stringsAsFactors = FALSE)
140+
combined <- rbind(existing, results)
141+
} else {
142+
combined <- results
143+
}
144+
write.csv(combined, out_csv, row.names = FALSE)
145+
cat("\nAppended to", out_csv, "(", nrow(combined), "rows )\n")

inst/site/bench_discords.R

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,13 @@ cases <- list(
103103
n = 5, warmup = 2),
104104
list(name = "wordbag_ecg0606",
105105
expr = quote(series_to_wordbag(x, 60, 6, 6, "exact", 0.01)),
106-
n = 10, warmup = 3)
106+
n = 10, warmup = 3),
107+
list(name = "rra_ecg0606_4disc",
108+
expr = quote(find_discords_rra(x, 100, 4, 4, "none", 0.01, 4)),
109+
n = 5, warmup = 2),
110+
list(name = "sax_medium_w160",
111+
expr = quote(sax_via_window(x_medium, 160, 4, 4, "none", 0.01)),
112+
n = 5, warmup = 2)
107113
)
108114

109115
results <- NULL

jmotif.R

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#' @useDynLib jmotif
2+
#' @importFrom Rcpp sourceCpp
3+
NULL
4+
5+
#' Generates a SAX MinDist distance matrix (i.e. the "lookup table") for a given alphabet size.
6+
#'
7+
#' @param a_size the desired alphabet size (a value between 2 and 20, inclusive)
8+
#' @return Returns a distance matrix (for SAX minDist) for a specified alphabet size
9+
#' @export
10+
#' @references Lonardi, S., Lin, J., Keogh, E., Patel, P.,
11+
#' Finding motifs in time series.
12+
#' In Proc. of the 2nd Workshop on Temporal Data Mining (pp. 53-68).
13+
#' @examples
14+
#' sax_distance_matrix(5)
15+
sax_distance_matrix <- function(a_size) {
16+
if (a_size > 1 && a_size <= 20) {
17+
cutlines <- jmotif::alphabet_to_cuts(a_size)[2:a_size]
18+
distance_matrix <- matrix(rep(0, a_size * a_size), byrow = T, nrow = a_size, ncol = a_size)
19+
i <- 1
20+
while (i <= a_size) {
21+
# the min_dist for adjacent symbols are 0, so we start with i+2
22+
j <- i + 2;
23+
while (j <= a_size) {
24+
# square the distance now for future use
25+
distance_matrix[i,j] <- (cutlines[i] - cutlines[j - 1]) * (cutlines[i] - cutlines[j - 1])
26+
# the distance matrix is symmetric
27+
distance_matrix[j,i] <- distance_matrix[i,j]
28+
j <- j + 1
29+
}
30+
i <- i + 1
31+
}
32+
distance_matrix
33+
} else {
34+
stop(paste("unable to get a distance matrix for the alphabet size",a_size))
35+
}
36+
}
37+
38+
#' Computes the mindist value for two strings
39+
#'
40+
#' @param str1 the first string
41+
#' @param str2 the second string
42+
#' @param alphabet_size the used alphabet size
43+
#' @param compression_ratio the distance compression ratio
44+
#' @return Returns the distance between strings
45+
#' @export
46+
#' @references Lonardi, S., Lin, J., Keogh, E., Patel, P.,
47+
#' Finding motifs in time series.
48+
#' In Proc. of the 2nd Workshop on Temporal Data Mining (pp. 53-68).
49+
#' @examples
50+
#' str1 <- c('a', 'b', 'c')
51+
#' str2 <- c('c', 'b', 'a')
52+
#' min_dist(str1, str2, 3)
53+
min_dist <- function(str1, str2, alphabet_size, compression_ratio = 1) {
54+
if (length(str1) != length(str2)) {
55+
stop("error: the strings must have equal length")
56+
}else{
57+
if ( any(letters_to_idx(str1) > alphabet_size) |
58+
any(letters_to_idx(str2) > alphabet_size)) {
59+
stop('error: some symbol(s) in the string(s) exceed(s)
60+
the alphabet size!');
61+
}else{
62+
dist_table <- sax_distance_matrix(alphabet_size)
63+
dist <- 0
64+
dist <- sqrt(
65+
compression_ratio *
66+
sum(diag(dist_table[letters_to_idx(str1), letters_to_idx(str2)]) ^ 2)
67+
)
68+
dist
69+
}
70+
}
71+
}
72+
73+
#' Computes the cosine distance between numeric vectors (1 minus cosine similarity).
74+
#'
75+
#' @param m the data matrix (rows are vectors).
76+
#' @return A \code{dist} object of pairwise cosine distances.
77+
#' @export
78+
#' @examples
79+
#' a <- c(2, 1, 0, 2, 0, 1, 1, 1)
80+
#' b <- c(2, 1, 1, 1, 1, 0, 1, 1)
81+
#' sim <- cosine_dist(rbind(a,b))
82+
cosine_dist <- function(m) {
83+
stats::as.dist(1 - m %*% t(m) / (sqrt(rowSums(m ^ 2) %*% t(rowSums(m ^ 2)))))
84+
}

src/hot-sax.cpp

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ discord_record find_best_discord_hotsax(std::vector<double>* ts, int w_size, dou
1111

1212
double best_so_far_distance = 0;
1313
int best_so_far_index = -1;
14-
std::string best_so_far_word = "";
1514

1615
VisitRegistry outerRegistry(ts->size() - w_size, seed);
1716

@@ -99,7 +98,6 @@ discord_record find_best_discord_hotsax(std::vector<double>* ts, int w_size, dou
9998
(nnDistance == best_so_far_distance && candidate_idx < best_so_far_index))){
10099
best_so_far_distance = nnDistance;
101100
best_so_far_index = candidate_idx;
102-
best_so_far_word = it->second;
103101
//Rcout << "updated discord record: "<< best_so_far_word << " at " << best_so_far_index <<
104102
// " nnDistance " << best_so_far_distance << "\n";
105103
}

src/sax.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ std::unordered_map<int, std::string> _sax_via_window(
271271
if (strategy_exact && old_str==curr_str) {
272272
continue;
273273
}
274-
else if (strategy_mindist && is_equal_mindist(old_str, curr_str) ) {
274+
else if (strategy_mindist && _is_equal_mindist(old_str, curr_str) ) {
275275
continue;
276276
}
277277
}

src/string.cpp

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
//' idx_to_letter(2)
1212
// [[Rcpp::export]]
1313
char idx_to_letter(int idx) {
14-
return LETTERS[idx-1];
14+
if (idx < 1 || idx > 26) {
15+
stop("'idx' is out of range [1, 26]");
16+
}
17+
return LETTERS[idx - 1];
1518
}
1619

1720
//' Get the index for an ASCII letter.
@@ -85,18 +88,17 @@ bool is_equal_mindist(CharacterVector a, CharacterVector b) {
8588
return true;
8689
}
8790

88-
/* bool _is_equal_mindist(std::string a, std::string b) {
91+
bool _is_equal_mindist(std::string a, std::string b) {
8992
if(a.length() != b.length()){
9093
return false;
91-
}else{
92-
for(unsigned i=0; i<a.length(); i++){
93-
if( abs(a[i] - b[i]) > 1 ){
94-
return false;
95-
}
94+
}
95+
for(unsigned i=0; i<a.length(); i++){
96+
if( abs(a[i] - b[i]) > 1 ){
97+
return false;
9698
}
9799
}
98100
return true;
99-
}*/
101+
}
100102

101103
int _count_spaces(std::string *s) {
102104
int count = 0;

test_str.R

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
test_that("idx to letter", {
2+
expect_true(is.character(idx_to_letter(3)))
3+
expect_equal(idx_to_letter(3), "c")
4+
expect_error(idx_to_letter(0))
5+
expect_error(idx_to_letter(27))
6+
})
7+
8+
test_that("letter to idx", {
9+
expect_true(is.numeric(letter_to_idx('c')))
10+
expect_equal(letter_to_idx('c'), 3)
11+
})
12+
13+
test_that("letters to idx", {
14+
expect_true(is.numeric(letters_to_idx(c('a', 'b', 'c', 'a'))))
15+
expect_true(is.vector(letters_to_idx(c('a', 'b', 'c', 'a'))))
16+
expect_equal(length(letters_to_idx(c('a', 'b', 'c', 'a'))), 4)
17+
expect_equal(letters_to_idx(c('a', 'b', 'c', 'a'))[3], 3)
18+
})

0 commit comments

Comments
 (0)