4  Tư duy thao tác dữ liệu trong R

library(knitr)
opts_chunk$set(
    message = FALSE
)

4.1 Bài toán

Hôm nay mình sẽ giới thiệu với các bạn một bài toán phân tích số liệu đơn giản trong R. Bài toán này nhằm giúp các bạn hiểu rõ hơn cách thức tư duy khi giải quyết một bài toán bằng lập trình.

Chúng ta có một bộ số liệu với 4 biến là stt (số thứ tự), gioi (giới: Nam, Nữ), do_tuoi (độ tuổi: <18, 18-45, >45), hailong (điểm hài lòng, từ 0 đến 100), và qol (điểm chất lượng cuộc sống, từ 0 đến 100). Đây là một bộ số liệu do mình tạo ra ngẫu nhiên ra thôi.

library(dplyr)
library(tidyr)

set.seed(0)
n <- 1000

d <- data.frame(
    stt = seq(n),
    gioi = factor(sample(c(1, 2), n, TRUE),
        levels = c(1, 2), labels = c("Nam", "Nu")),
    do_tuoi = factor(sample(c(1, 2, 3), n, TRUE),
        levels = c(1, 2, 3), labels = c("<18", "18-45", ">45")),
    hailong = round(runif(n, 0, 100), 1),
    qol = round(runif(n, 0, 100), 1)
)

d %>% head(5) %>% kable()
stt gioi do_tuoi hailong qol
1 Nu >45 33.0 76.3
2 Nam >45 69.7 23.6
3 Nu >45 35.4 28.6
4 Nam <18 40.6 31.8
5 Nam >45 30.8 92.2

Việc của chúng ta sẽ là tạo ra một bảng phân tích kết quả trông như sau:

Đặc điểm Nhóm Hài lòng, mean (SD)
Giới Nam
Giới Nữ
Độ tuổi <18
Độ tuổi 18-45
Độ tuổi >45

Có nhiều cách để làm việc này. Cách mà mình giới thiệu hôm nay khá trực tiếp, mặc dù có thể không phải là cách tối ưu.

4.2 Các phép thao tác với số liệu

Có 4 phép thao tác (manipulate) số liệu chính:

  • Tái cấu trúc (reshaping): chuyển số liệu từ dạng bảng dài sang dạng bảng ngang và ngược lại (pivot giữa long / wide data), xếp chồng các số liệu lên nhau (stacking / unstacking), v.v.. Thư viện sử dụng cho reshaping là tidyr.
  • Nhóm (grouping): các số liệu thuộc cùng một nhóm được xếp chung với nhau để phục vụ một mục đích nào đó. Bạn chắc đã làm quen với hàm dplyr::group_by() cho việc này.
  • Chuyển dạng (transformation): chuyển số liệu cá thể thành các giá trị mới dựa trên một phép biến đổi nào đó như chuẩn hóa (normalization), logarit, chia nhóm (categorization), v.v.. Mọi phép chuyển dạng đều thông qua hàm dplyr::mutate() và các biến thể của nó.
  • Tổng hợp (aggregation): tính toán các chỉ số tổng hợp (trung bình, tỉ lệ phần trăm, v.v.) từ số liệu cá thể. Hầu hết các phép tổng hợp đều thông qua hàm dplyr::summarize() và các biến thể của nó.

Bằng những phép thao tác số liệu này, chúng ta có thể tạo ra mọi kết quả mong muốn từ một bộ số liệu gốc.

4.3 Tư duy thao tác số liệu

Nhìn vào bộ số liệu gốc, mình nghĩ rằng sẽ cần tạo ra một (hoặc nhiều) bộ số liệu trung gian để phục vụ việc tính toán như trên. Bộ số liệu trung gian sẽ có cấu trúc như thế nào? Quan sát bảng phân tích kết quả, chúng ta thấy rằng:

  • Cột “Đặc điểm” là tên các biến mà chúng ta có trong bộ số liệu gốc.
  • Cột “Nhóm” là các giá trị của các biến “Đặc điểm” có trong bộ số liệu gốc.
  • Cột “Hài lòng” là kết quả tổng hợp của điểm hài lòng trong bộ số liệu gốc.

Vậy bộ số liệu trung gian của mình có thể là kết quả chuyển từ dạng ngang (các biến xếp thành từng cột) sang dạng dài (các biến xếp chồng lên nhau) của hai biến gioido_tuoi, còn giữ lại biến hailong. Ví dụ:

stt variable value hailong
1 gioi Nu 33.0
1 do_tuoi >45 33.0
2 gioi Nam 69.7
2 do_tuoi >45 69.7

Sau đó mình chỉ việc tạo ra các nhóm của Đặc điểm (variable) và Nhóm (value) để tổng hợp (aggregate) cột hailong. Hãy cùng xem chúng ta thực thi kế hoạch này.

4.3.1 Bước 1: Reshaping

d_long <- d %>%
    select(gioi, do_tuoi, hailong) %>%
    pivot_longer(cols = c(gioi, do_tuoi), names_to = "variable")

d_long %>% head() %>% kable()
hailong variable value
33.0 gioi Nu
33.0 do_tuoi >45
69.7 gioi Nam
69.7 do_tuoi >45
35.4 gioi Nu
35.4 do_tuoi >45

4.3.2 Bước 2: Grouping và Aggregation

d_agg <- d_long %>%
    group_by(variable, value) %>%
    summarize(
        mean = mean(hailong),
        sd = sd(hailong)
    )

d_agg %>% kable()
variable value mean sd
do_tuoi <18 49.97685 28.50213
do_tuoi 18-45 50.87988 29.54522
do_tuoi >45 49.84675 28.96249
gioi Nam 49.64345 29.21245
gioi Nu 50.84234 28.77515

4.3.3 Bước 3: Transformation

d_agg %>%
    mutate(
        mean_sd = sprintf("%.1f (%.1f)", mean, sd)
    ) %>%
    select(variable, value, mean_sd) %>%
    kable()
variable value mean_sd
do_tuoi <18 50.0 (28.5)
do_tuoi 18-45 50.9 (29.5)
do_tuoi >45 49.8 (29.0)
gioi Nam 49.6 (29.2)
gioi Nu 50.8 (28.8)

4.4 Module hóa công việc

Như ở trên, bạn đã thấy chúng ta thống kê được mean (SD) của điểm hài lòng. Nhưng nếu chúng ta muốn làm tương tự như vậy với điểm chất lượng cuộc sống và gộp chung kết quả với điểm hài lòng thì bạn sẽ làm thế nào? Tất nhiên, bạn hoàn toàn có thể thêm tạo ra các biến mean_qolsd_qol cho điểm chất lượng cuộc sống trong Bước 2, nhưng nếu không phải là 2 biến mà là 20 biến, thì việc đó sẽ rất phiền toái, hoặc nếu bạn phải thay đổi kế hoạch phân tích, loại bỏ biến qol và thêm biến khác vào. Đây là lúc bạn cần dùng đến hàm, và chúng ta gọi đây là module hóa công việc.

Ba bước ở trên có thể được tóm gọn trong một hàm như sau.

library(rlang)

get_mean_sd <- function(d, group_vars, outcome_var) {
    d %>%
        # Bước 1
        select(all_of(c(group_vars, outcome_var))) %>%
        pivot_longer(cols = all_of(group_vars), names_to = "variable") %>%

        # Bước 2
        group_by(variable, value) %>%
        summarize(
            mean = mean(!!sym(outcome_var)),
            sd = sd(!!sym(outcome_var))
        ) %>%

        # Bước 3
        mutate(
            outcome = outcome_var,
            mean_sd = sprintf("%.1f (%.1f)", mean, sd)
        ) %>%
        select(variable, value, outcome, mean_sd)
}

group_vars <- c("gioi", "do_tuoi")
outcome_var <- "hailong"
get_mean_sd(d, group_vars, outcome_var) %>% kable()
variable value outcome mean_sd
do_tuoi <18 hailong 50.0 (28.5)
do_tuoi 18-45 hailong 50.9 (29.5)
do_tuoi >45 hailong 49.8 (29.0)
gioi Nam hailong 49.6 (29.2)
gioi Nu hailong 50.8 (28.8)

Và chúng ta có thể tự động hóa việc tính toán này cho nhiều biến kết cục khác nhau.

library(purrr)

outcome_vars <- c("hailong", "qol")
map_df(outcome_vars, ~ get_mean_sd(d, group_vars, .x)) %>% kable()
variable value outcome mean_sd
do_tuoi <18 hailong 50.0 (28.5)
do_tuoi 18-45 hailong 50.9 (29.5)
do_tuoi >45 hailong 49.8 (29.0)
gioi Nam hailong 49.6 (29.2)
gioi Nu hailong 50.8 (28.8)
do_tuoi <18 qol 49.3 (30.0)
do_tuoi 18-45 qol 48.7 (30.4)
do_tuoi >45 qol 49.3 (30.7)
gioi Nam qol 49.9 (29.7)
gioi Nu qol 48.4 (31.0)

Tất nhiên, nếu bạn muốn chuyển sang dạng nhiều cột kết quả của các biến kết cục thì cũng rất đơn giản, nó chỉ là pivot từ dạng long sang wide thôi.

map_df(outcome_vars, ~ get_mean_sd(d, group_vars, .x)) %>%
    pivot_wider(id_cols = c(variable, value),
        names_from = outcome, values_from = mean_sd) %>%
    kable()
variable value hailong qol
do_tuoi <18 50.0 (28.5) 49.3 (30.0)
do_tuoi 18-45 50.9 (29.5) 48.7 (30.4)
do_tuoi >45 49.8 (29.0) 49.3 (30.7)
gioi Nam 49.6 (29.2) 49.9 (29.7)
gioi Nu 50.8 (28.8) 48.4 (31.0)

Và bạn có thể gói tiếp chức năng này trong một hàm như sau:

get_mean_sd_all <- function(d, group_vars, outcome_vars) {
    map_df(outcome_vars, ~ get_mean_sd(d, group_vars, .x)) %>%
        pivot_wider(id_cols = c(variable, value),
            names_from = outcome, values_from = mean_sd)
}

get_mean_sd_all(d, group_vars, outcome_vars) %>% kable()
variable value hailong qol
do_tuoi <18 50.0 (28.5) 49.3 (30.0)
do_tuoi 18-45 50.9 (29.5) 48.7 (30.4)
do_tuoi >45 49.8 (29.0) 49.3 (30.7)
gioi Nam 49.6 (29.2) 49.9 (29.7)
gioi Nu 50.8 (28.8) 48.4 (31.0)

Những tính năng thuộc về lập trình cho dplyrpurrr như dấu chấm than kép (!!), hàm rlang::sym(), hàm dplyr::all_of(), và hàm purrr:map_df() mình sẽ giới thiệu cụ thể trong một bài khác. Chúng ta sẽ dừng lại bài này ở đây, vì hi vọng bạn đã hiểu rõ hơn cách chúng ta tư duy khi lập trình để thao tác với số liệu. Mình tổng hợp lại kết quả ở dưới đây nhé.

library(dplyr)
library(tidyr)
library(rlang)
library(purrr)

# Tính mean (SD) cho một biến
get_mean_sd <- function(d, group_vars, outcome_var) {
    d %>%
        # Bước 1
        select(all_of(c(group_vars, outcome_var))) %>%
        pivot_longer(cols = all_of(group_vars), names_to = "variable") %>%

        # Bước 2
        group_by(variable, value) %>%
        summarize(
            mean = mean(!!sym(outcome_var)),
            sd = sd(!!sym(outcome_var))
        ) %>%

        # Bước 3
        mutate(
            outcome = outcome_var,
            mean_sd = sprintf("%.1f (%.1f)", mean, sd)
        ) %>%
        select(variable, value, outcome, mean_sd)
}

# Tính mean (SD) cho tất cả các biến
get_mean_sd_all <- function(d, group_vars, outcome_vars) {
    map_df(outcome_vars, ~ get_mean_sd(d, group_vars, .x)) %>%
        pivot_wider(id_cols = c(variable, value),
            names_from = outcome, values_from = mean_sd)
}

group_vars <- c("gioi", "do_tuoi")
outcome_vars <- c("hailong", "qol")
get_mean_sd_all(d, group_vars, outcome_vars)