www.tuhocr.comBạn có folder này chứa 332 file .csv tương ứng 332 cảm
biến theo dõi chỉ tiêu sulfate và nitrate chất lượng nước ở các địa điểm
khác nhau (số liệu mô phỏng, không có thực). Nguồn dataset từ
Coursera/Data Science Specialization.
Download dataset
sau đó giải nén ra folder specdata rồi đặt trong folder
project R.
Cấu trúc của 1 file .csv như sau:
data_020 <- read.csv("specdata/020.csv")
head(data_020) # xem 6 dòng đầu tiên## Date sulfate nitrate ID
## 1 2002-01-01 NA NA 20
## 2 2002-01-02 NA NA 20
## 3 2002-01-03 NA NA 20
## 4 2002-01-04 NA NA 20
## 5 2002-01-05 NA NA 20
## 6 2002-01-06 NA NA 20
dim(data_020) # kiểm tra số hàng và cột## [1] 1461 4
sapply(data_020, class) # kiểm tra `class` từng cột## Date sulfate nitrate ID
## "character" "numeric" "numeric" "integer"
summary(data_020) # tóm tắt dataset## Date sulfate nitrate ID
## Length:1461 Min. : 0.472 Min. :0.1080 Min. :20
## Class :character 1st Qu.: 2.147 1st Qu.:0.3032 1st Qu.:20
## Mode :character Median : 3.180 Median :0.4740 Median :20
## Mean : 3.625 Mean :0.7780 Mean :20
## 3rd Qu.: 4.890 3rd Qu.:0.8140 3rd Qu.:20
## Max. :14.400 Max. :6.7700 Max. :20
## NA's :1337 NA's :1333
Trong dataset này có hai chỉ tiêu sulfate và
nitrate được cảm biến đo độc lập nhau. Có những ngày ghi
nhận được giá trị sulfate hoặc nitrate, có
những ngày không ghi nhận giá trị gì cả (missing value), cũng như có
ngày ghi nhận đầy đủ cả 2 giá trị sulfate và
nitrate.
Bạn hãy xây dựng 2 function khác nhau để tìm ra có bao nhiêu giá trị quan sát đầy đủ tương ứng ở từng cảm biến, để làm cơ sở đánh giá xem các cảm biến ở những vị trí khác nhau thì số lượng kết quả theo dõi có đồng đều nhau hay không.
Sau đó so sánh tốc độ xử lý, trả kết quả của hai function này.
Cách tiếp cận function complete_1 này sử dụng hàm
for loop để đọc file .csv gom trong 1 list
master sau đó gom chung lại thành 1 dataset master rồi mới lọc dữ liệu
những dòng nào chứa đủ cả 2 giá trị quan sát ở từng dataset con, sau đó
trả về kết quả chung.
complete_1 <- function(directory = "specdata", id) {
# import dataset
files_list <- list.files(directory, full.names = TRUE)
tmp <- files_list[c(id)] # Đây là chỗ tùy chọn
tmp_working <- vector(mode = "list", length = length(tmp))
for (i in seq_along(tmp)) {
tmp_working[[i]] <- read.csv(tmp[[i]]) # Kiểu subset của tmp_working là dạng list tách ra dạng bảng
}
# gom chung các dataset lại làm 1 dataset master
output_tmp_working <- do.call(rbind, tmp_working)
y <- unique(output_tmp_working$ID) # Đếm số lần xuất hiện (number of occurences for each unique value)
extract_data <- data.frame(matrix(ncol = 2, nrow = 0))
for (uu in seq_along(y)) {
happy <- output_tmp_working[which(output_tmp_working[, "ID"] == y[uu]), ]
sul <- happy[, 2]
nit <- happy[, 3]
tot <- sul + nit
sum(is.na(tot)) # Hàm đếm missing values trong vector
nobs <- length(tot) - sum(is.na(tot))
extract_data <- rbind(extract_data, c(y[uu], nobs))
}
# trả kết quả
x <- c("cam_bien", "full_quan_sat")
colnames(extract_data) <- x
return(extract_data)
}Test hàm complete_1 kiểm tra các dataset
id từ 20 đến 40.
complete_1(directory = "specdata", id = 20:40)## cam_bien full_quan_sat
## 1 20 124
## 2 21 426
## 3 22 135
## 4 23 492
## 5 24 885
## 6 25 463
## 7 26 586
## 8 27 338
## 9 28 475
## 10 29 711
## 11 30 932
## 12 31 483
## 13 32 616
## 14 33 466
## 15 34 165
## 16 35 509
## 17 36 495
## 18 37 497
## 19 38 491
## 20 39 734
## 21 40 21
Cách tiếp cận function complete_2 sử dụng hàm
lapply loop để đọc file .csv gom trong 1 list
master. Sau đó tạo 1 hàm con trong này để đếm ngày quan sát chỉ tiêu
sulfate và nitrate trùng nhau (tức là quan sát
full chỉ tiêu), rồi xuất ra kết quả thông qua lệnh sapply.
Như vậy sẽ tiết kiệm thời gian hơn gom chung 1 dataset master như cách
1.
complete_2 <- function(directory = "specdata", id) {
# import dataset
files_list <- list.files(directory,
full.names = TRUE,
pattern = ".csv"
) # set này để chọn riêng file .csv
tmp_working <- lapply(files_list[id], read.csv)
# function con để check giá trị full quan sát 2 chỉ tiêu
check_full_quan_sat <- function(check_id) {
raw <- tmp_working[[check_id]]
check_1 <- which(!is.na(raw$nitrate))
check_2 <- which(!is.na(raw$sulfate))
ra_soat <- intersect(check_1, check_2)
length(ra_soat)
}
# áp dụng sapply cho seq_along(tmp_working)
full_quan_sat <- sapply(seq_along(tmp_working), check_full_quan_sat)
full_quan_sat <- as.data.frame(full_quan_sat) # sapply trả về matrix, cần convert qua lại data frame
# trả kết quả
ket_qua <- cbind(cam_bien = id, full_quan_sat)
return(ket_qua)
}Test hàm complete_2 kiểm tra các dataset
id từ 20 đến 40.
complete_2(directory = "specdata", id = 20:40)## cam_bien full_quan_sat
## 1 20 124
## 2 21 426
## 3 22 135
## 4 23 492
## 5 24 885
## 6 25 463
## 7 26 586
## 8 27 338
## 9 28 475
## 10 29 711
## 11 30 932
## 12 31 483
## 13 32 616
## 14 33 466
## 15 34 165
## 16 35 509
## 17 36 495
## 18 37 497
## 19 38 491
## 20 39 734
## 21 40 21
Việc giải bài toán trong R thường đi từ các cách tiếp cận khác nhau
phụ thuộc vào mức độ am hiểu với những câu lệnh nào mà chúng ta biết.
Mục tiêu vẫn là trả được kết quả như yêu cầu đề bài. Tuy nhiên khi xử lý
những tình huống phức tạp, thì các function nào xây dựng dựa trên những
dòng lệnh an toàn (ví dụ dùng các lệnh họ apply thay vì
dùng for loop để tránh bị trùng biến trung gian) và thông
minh (xử lý theo yêu cầu thay vì gom lại tổng thể rồi subset) sẽ giúp
tăng tốc độ trả về kết quả, nhanh chóng có câu trả lời.
Vì vậy việc đánh giá hiệu suất giữa hai function là cần thiết để chúng ta có cơ sở nên dùng function nào, và cách tiếp cận nào phù hợp với từng tình huống cụ thể.
Tiêu chí đánh giá function trong R, theo mình, dựa vào các chỉ tiêu sau:
1/ Các câu lệnh sử dụng không xuất hiện biến trung gian (nếu có), vì
khi xây dựng một đoạn code lớn thì khả năng trùng tên biến khá cao. Nếu
am hiểu lệnh họ apply thì các bạn có thể dùng thay thế
for loop.
2/ Cách giải quyết bài toán trực tiếp theo kiểu lazy function, tức là khi nào cần thì mới gọi ra xử lý (như cách 2, vì dataset nằm trong list master), thay vì gom lại 1 dataset master (như cách 1) thì gây tốn thời gian và CPU máy tính.
3/ Độ dài ngắn của script code thì không nói lên điều gì về mức độ
đoạn code đó hiệu quả hay không. Mà chủ yếu cách trình bày format script
code có comment dễ hiểu, giúp chúng ta nhìn vào hiểu được ý tưởng của
từng code block trong đó, thay vì phải mất time suy luận coi đoạn code
này giải quyết vấn đề gì. Tất nhiên, một đoạn code ngắn gọn, súc tích,
đáp ứng tiêu chí 1/ và 2/ sẽ hiệu quả hơn là cách viết code rườm rà (vì
càng cồng kềnh thì khả năng xảy ra lỗi càng cao, đôi khi chỉ là thiếu
dấu {} đóng mở ngoặc).
identical() so sánh kết quảSau khi xây dựng function thì việc test hàm ở các tình huống khác
nhau là rất cần thiết để dự trù trước hầu như mọi chuyện sẽ xảy ra khi
áp dụng function trong thực tế. Nếu bạn có hai function với cách tiếp
cận khác nhau nhưng để đảm bảo cả 2 function này hoạt động trả về cùng
kết quả y chang nhau (nghĩa là nó có cùng chức năng) thì việc dùng hàm
identical sẽ là khâu confirm quyết định sự thành công của
function cải tiến so với function chuẩn ban đầu.
Ở đây chúng ta sẽ test các tình huống đọc dữ liệu các dataset từ 1 đến 332 (toàn bộ file .csv) để xem kết quả của hai function này ra sao.
library(DT) # xuất kết quả dạng bảng pagination
full_1 <- complete_1(directory = "specdata", id = 1:332)
full_2 <- complete_2(directory = "specdata", id = 1:332)
datatable(full_1, options = list(pageLength = 5))datatable(full_2, options = list(pageLength = 5))# nếu kết quả lệnh `identical()` là TRUE thì cho thấy kết quả xuất ra từ hai function này y chang nhau.
identical(complete_1(directory = "specdata", id = 1:332),
complete_2(directory = "specdata", id = 1:332))## [1] TRUE
system.time() để đo tốc độ xử lýSau khi xác định được function complete_1 và
complete_2 có khả năng như nhau, thì việc lựa chọn function
nào phụ thuộc vào thời gian trả kết quả của function đó nhanh hay
chậm.
Ta áp dụng một trong hai cách sau lệnh
system.time() hoặc R
profiler để đánh giá hiệu suất xử lý.
# thời gian để thực thi function `complete_1`
time_1 <- system.time(complete_1(directory = "specdata", id = 1:332))
time_1## user system elapsed
## 6.00 1.65 9.54
# thời gian để thực thi function `complete_2`
time_2 <- system.time(complete_2(directory = "specdata", id = 1:332))
time_2## user system elapsed
## 2.23 0.50 2.81
Các bạn so sánh user time là thời gian CPU máy tính chạy
để thực thi function cũng như elapsed time là tổng thời
gian thực thi câu lệnh của hai function này, kết quả là function
complete_2 chạy nhanh hơn 3.4 lần so với
function complete_1.
Rprof()
để phân tích tốc độ xử lýNếu chúng ta muốn biết chính xác trong nội bộ từng lệnh nhỏ ở trong từng function hoạt động nhanh, chậm ra sao, khâu nào là chậm nhất (để có cơ sở cải tiến đoạn code đó), thì ta áp dụng R profiler, với cách tiếp cận là:
1/ Gọi lệnh Rprof() để báo cho R console biết là sẽ theo
dõi các function sắp gọi ra. 2/ Gọi function cần thực thi 3/ Xuất kết
quả profiling (theo dõi function) bằng lệnh
summaryRprof()
Ở đây, ta sẽ kiểm tra xem ở trong từng function
complete_1 và complete_2 thì cụ thể những lệnh
nào thực thi bao lâu, tổng thời gian thực thi như thế nào (kết quả này
sẽ khác đôi chút với cách tiếp cận dùng lệnh system.time()
ở trên.
Kết quả profiling function
complete_1
Rprof() # theo dõi function
profile_1 <- complete_1(directory = "specdata", id = 1:332) # gọi function, đưa kết quả vào dataset
report_1 <- summaryRprof() # xuất báo cáo tốc độ xử lý từng hàm con trong function
report_1## $by.self
## self.time self.pct total.time total.pct
## "==" 1.14 27.94 1.14 27.94
## "scan" 1.06 25.98 1.06 25.98
## "rbind" 0.50 12.25 0.56 13.73
## ".External2" 0.40 9.80 0.40 9.80
## "which" 0.36 8.82 1.50 36.76
## "read.table" 0.08 1.96 1.84 45.10
## "file" 0.08 1.96 0.08 1.96
## "[.data.frame" 0.06 1.47 1.64 40.20
## "anyDuplicated.default" 0.06 1.47 0.06 1.47
## "eval" 0.04 0.98 4.08 100.00
## "%in%" 0.04 0.98 0.04 0.98
## ".External" 0.04 0.98 0.04 0.98
## "close.connection" 0.04 0.98 0.04 0.98
## "type.convert" 0.02 0.49 0.46 11.27
## "type.convert.default" 0.02 0.49 0.44 10.78
## "make.names" 0.02 0.49 0.06 1.47
## "match.arg" 0.02 0.49 0.06 1.47
## "attr" 0.02 0.49 0.02 0.49
## "lengths" 0.02 0.49 0.02 0.49
## "Rprof" 0.02 0.49 0.02 0.49
## "structure" 0.02 0.49 0.02 0.49
## "unique.default" 0.02 0.49 0.02 0.49
##
## $by.total
## total.time total.pct self.time self.pct
## "eval" 4.08 100.00 0.04 0.98
## "eval_with_user_handlers" 4.08 100.00 0.00 0.00
## "handle" 4.08 100.00 0.00 0.00
## "timing_fn" 4.08 100.00 0.00 0.00
## "withCallingHandlers" 4.08 100.00 0.00 0.00
## "withVisible" 4.08 100.00 0.00 0.00
## "block_exec" 4.06 99.51 0.00 0.00
## "call_block" 4.06 99.51 0.00 0.00
## "complete_1" 4.06 99.51 0.00 0.00
## "eng_r" 4.06 99.51 0.00 0.00
## "evaluate" 4.06 99.51 0.00 0.00
## "evaluate::evaluate" 4.06 99.51 0.00 0.00
## "evaluate_call" 4.06 99.51 0.00 0.00
## "in_dir" 4.06 99.51 0.00 0.00
## "in_input_dir" 4.06 99.51 0.00 0.00
## "knitr::knit" 4.06 99.51 0.00 0.00
## "process_file" 4.06 99.51 0.00 0.00
## "process_group" 4.06 99.51 0.00 0.00
## "process_group.block" 4.06 99.51 0.00 0.00
## "rmarkdown::render" 4.06 99.51 0.00 0.00
## "read.table" 1.84 45.10 0.08 1.96
## "read.csv" 1.84 45.10 0.00 0.00
## "[.data.frame" 1.64 40.20 0.06 1.47
## "[" 1.64 40.20 0.00 0.00
## "which" 1.50 36.76 0.36 8.82
## "==" 1.14 27.94 1.14 27.94
## "scan" 1.06 25.98 1.06 25.98
## "rbind" 0.56 13.73 0.50 12.25
## "<Anonymous>" 0.50 12.25 0.00 0.00
## "do.call" 0.50 12.25 0.00 0.00
## "type.convert" 0.46 11.27 0.02 0.49
## "type.convert.default" 0.44 10.78 0.02 0.49
## ".External2" 0.40 9.80 0.40 9.80
## "file" 0.08 1.96 0.08 1.96
## "anyDuplicated.default" 0.06 1.47 0.06 1.47
## "make.names" 0.06 1.47 0.02 0.49
## "match.arg" 0.06 1.47 0.02 0.49
## "anyDuplicated" 0.06 1.47 0.00 0.00
## "%in%" 0.04 0.98 0.04 0.98
## ".External" 0.04 0.98 0.04 0.98
## "close.connection" 0.04 0.98 0.04 0.98
## "close" 0.04 0.98 0.00 0.00
## "order" 0.04 0.98 0.00 0.00
## "attr" 0.02 0.49 0.02 0.49
## "lengths" 0.02 0.49 0.02 0.49
## "Rprof" 0.02 0.49 0.02 0.49
## "structure" 0.02 0.49 0.02 0.49
## "unique.default" 0.02 0.49 0.02 0.49
## "[[" 0.02 0.49 0.00 0.00
## "[[.data.frame" 0.02 0.49 0.00 0.00
## "evaluate_call 0.02 0.49 0.00 0.00
## "unique" 0.02 0.49 0.00 0.00
##
## $sample.interval
## [1] 0.02
##
## $sampling.time
## [1] 4.08
Kết quả profiling function
complete_2
Rprof()
profile_2 <- complete_2(directory = "specdata", id = 1:332)
report_2 <- summaryRprof()
report_2## $by.self
## self.time self.pct total.time total.pct
## "scan" 1.02 50.50 1.04 51.49
## ".External2" 0.34 16.83 0.34 16.83
## "file" 0.18 8.91 0.20 9.90
## "read.table" 0.12 5.94 1.90 94.06
## "close.connection" 0.06 2.97 0.06 2.97
## "character" 0.04 1.98 0.04 1.98
## "pmatch" 0.04 1.98 0.04 1.98
## "handle" 0.02 0.99 2.00 99.01
## "FUN" 0.02 0.99 1.94 96.04
## "match.arg" 0.02 0.99 0.06 2.97
## "order" 0.02 0.99 0.04 1.98
## "%in%" 0.02 0.99 0.02 0.99
## "getOption" 0.02 0.99 0.02 0.99
## "is.array" 0.02 0.99 0.02 0.99
## "numeric" 0.02 0.99 0.02 0.99
## "strsplit" 0.02 0.99 0.02 0.99
## "which" 0.02 0.99 0.02 0.99
## lers" 0.02 0.99 0.02 0.99
##
## $by.total
## total.time total.pct self.time self.pct
## "handle" 2.00 99.01 0.02 0.99
## "evaluate" 2.00 99.01 0.00 0.00
## "evaluate::evaluate" 2.00 99.01 0.00 0.00
## "evaluate_call" 2.00 99.01 0.00 0.00
## "in_dir" 2.00 99.01 0.00 0.00
## "timing_fn" 2.00 99.01 0.00 0.00
## "withCallingHandlers" 2.00 99.01 0.00 0.00
## "block_exec" 1.98 98.02 0.00 0.00
## "call_block" 1.98 98.02 0.00 0.00
## "eng_r" 1.98 98.02 0.00 0.00
## "eval" 1.98 98.02 0.00 0.00
## "eval_with_user_handlers" 1.98 98.02 0.00 0.00
## "in_input_dir" 1.98 98.02 0.00 0.00
## "knitr::knit" 1.98 98.02 0.00 0.00
## "process_file" 1.98 98.02 0.00 0.00
## "process_group" 1.98 98.02 0.00 0.00
## "process_group.block" 1.98 98.02 0.00 0.00
## "rmarkdown::render" 1.98 98.02 0.00 0.00
## "withVisible" 1.98 98.02 0.00 0.00
## "FUN" 1.94 96.04 0.02 0.99
## "complete_2" 1.94 96.04 0.00 0.00
## "lapply" 1.94 96.04 0.00 0.00
## "read.table" 1.90 94.06 0.12 5.94
## "scan" 1.04 51.49 1.02 50.50
## "type.convert" 0.44 21.78 0.00 0.00
## "type.convert.default" 0.44 21.78 0.00 0.00
## ".External2" 0.34 16.83 0.34 16.83
## "file" 0.20 9.90 0.18 8.91
## "close.connection" 0.06 2.97 0.06 2.97
## "match.arg" 0.06 2.97 0.02 0.99
## "close" 0.06 2.97 0.00 0.00
## "character" 0.04 1.98 0.04 1.98
## "pmatch" 0.04 1.98 0.04 1.98
## "order" 0.04 1.98 0.02 0.99
## "make.names" 0.04 1.98 0.00 0.00
## "sapply" 0.04 1.98 0.00 0.00
## "summaryRprof" 0.04 1.98 0.00 0.00
## "%in%" 0.02 0.99 0.02 0.99
## "getOption" 0.02 0.99 0.02 0.99
## "is.array" 0.02 0.99 0.02 0.99
## "numeric" 0.02 0.99 0.02 0.99
## "strsplit" 0.02 0.99 0.02 0.99
## "which" 0.02 0.99 0.02 0.99
## lers" 0.02 0.99 0.02 0.99
## "intersect" 0.02 0.99 0.00 0.00
## "vapply" 0.02 0.99 0.00 0.00
## "withCallingHand 0.02 0.99 0.00 0.00
##
## $sample.interval
## [1] 0.02
##
## $sampling.time
## [1] 2.02
Kết quả thời gian sampling.time xem như
đại diện cho tổng thời gian thực thi câu lệnh trong function
complete_1 là 4.08 (tính theo giây) lâu hơn trong function
complete_2 là 2.02, hay nói cách khác function
complete_2 có thời gian xử lý kết quả nhanh hơn function
complete_1.
Gom kết quả vào matrix để vẽ đồ thị cột
# tạo matrix
f1 <- c(unclass(time_1)[1], unclass(time_1)[2], unclass(time_1)[3], report_1$sampling.time)
f2 <- c(unclass(time_2)[1], unclass(time_2)[2], unclass(time_2)[3], report_2$sampling.time)
barplot_data <- cbind(f1, f2)
colnames(barplot_data) <- c("complete_1", "complete_2")
rownames(barplot_data) <- c("user time", "system time", "elapsed time", "sampling time")
barplot_data## complete_1 complete_2
## user time 6.00 2.23
## system time 1.65 0.50
## elapsed time 9.54 2.81
## sampling time 4.08 2.02
Vẽ đồ thị cột
Lưu ý là mỗi lần ta render file .Rmd thì R sẽ chạy lại
toàn bộ các lệnh đo lường tốc độ xử lý nên kết quả thu được sẽ khác biệt
nhau tùy vào tốc độ CPU ở thời điểm đó.
# Define a set of colors
my_colors <- c("palegreen1", "yellow1", "salmon1", "cornsilk")
# Bar plot
barplot(barplot_data,
col = my_colors,
beside = TRUE,
ylim = c(0, yes),
xlab = "Chỉ tiêu theo dõi",
ylab = "Thời gian (giây)",
main = "So sánh tốc độ xử lý giữa hai function | tuhocr.com",
las = 1
)
# Add legend
legend("top",
legend = rownames(barplot_data),
horiz = TRUE,
fill = my_colors,
box.lty = 0,
cex = 1
)
# Đưa đường baseline
abline(h = max(barplot_data[, 2]), col = "red", lty = 2, lwd = 2)
# Ghi thêm text trên abline
text(8,
max(barplot_data[, 2]),
"Thời gian xử lý của complete_2 \n luôn thấp hơn complete_1",
srt = 0,
pos = 3,
cex = 1,
col = "blue")Như vậy, function complete_2 có tốc độ xử lý
nhanh hơn function complete_1 nhờ áp dụng các lệnh
apply cũng như tiếp cận theo cách subset dữ liệu từ list
master thay vì gom chung thành data frame master.
Trên đây là ví dụ minh họa cho bài giảng Cách đánh giá tốc độ function trong khóa học “HDSD R để xử lý dữ liệu | Chuyên đề Coding in R”, sau 20 giờ học, bạn sẽ có nền tảng vững chắc về R căn bản và cách xây dựng function cho riêng mình!
Nội dung khóa học:
www.tuhocr.com
Hành trình ngàn dặm bắt đầu từ bước chân đầu tiên.
ĐĂNG KÝ NGAY:
https://www.tuhocr.com/register
sessionInfo()## R version 4.2.1 (2022-06-23 ucrt)
## Platform: x86_64-w64-mingw32/x64 (64-bit)
## Running under: Windows 10 x64 (build 19041)
##
## Matrix products: default
##
## locale:
## [1] LC_COLLATE=English_United States.utf8
## [2] LC_CTYPE=English_United States.utf8
## [3] LC_MONETARY=English_United States.utf8
## [4] LC_NUMERIC=C
## [5] LC_TIME=English_United States.utf8
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] DT_0.26
##
## loaded via a namespace (and not attached):
## [1] digest_0.6.29 R6_2.5.1 jsonlite_1.8.0 magrittr_2.0.3
## [5] evaluate_0.16 highr_0.9 stringi_1.7.8 cachem_1.0.6
## [9] rlang_1.0.6 cli_3.4.0 rstudioapi_0.14 jquerylib_0.1.4
## [13] bslib_0.4.0 rmarkdown_2.16 tools_4.2.1 stringr_1.4.1
## [17] htmlwidgets_1.5.4 crosstalk_1.2.0 xfun_0.33 yaml_2.3.5
## [21] fastmap_1.1.0 compiler_4.2.1 htmltools_0.5.3 knitr_1.40
## [25] sass_0.4.2