Chapter 6 第六章 文本数据分析

学习目标

  • 📌 掌握词汇分析基本方法

  • 📌 掌握短语提取基本方法

  • 📌 掌握句法分析基本方法

  • 📌 掌握语篇分析基本方法

6.1 语言分析的单位

6.2 语言分析的维度

6.3 自然语言处理

6.3.1 分词

6.3.2 词形还原与词干提取

6.3.3 词性标注

6.3.4 命名实体识别

6.3.5 句法分析

6.3.6 语义角色标注

6.3.7 语义类别标注

6.4 语料库量化分析方法

6.4.1 词汇分析

6.4.1.1 高频词分析

6.4.1.2 关键词分析

6.4.1.3 共现语境分析

6.4.2 短语分析

6.4.3 句法分析

6.4.4 语篇分析

6.4.4.1 词汇丰富度分析

6.4.4.2 文本信息熵

6.4.4.3 文本可读性

6.4.4.4 情感分析

6.4.4.5 计算文体分析

6.5 课堂任务

任务6.1 高频词分析

  我们还可以通过 tidytext 包的 tidy() 函数,将语料库转换为整洁数据框格式,以便使用 tidyverse 工具进一步处理和可视化。

任务6.2 关键词分析

6.5.0.2 tidytext

# 对比狄仁杰和福尔摩斯
texts_df = detectives.raw%>%
  unnest_tokens(word, text)%>%
  mutate(doc_id = dplyr::recode(doc_id , "Dee.txt" = "text1",
                                  "Sherlock.txt" = "text2"))%>%
  group_by(doc_id,word)%>%
  summarise(freq = n())%>%
  spread(doc_id, freq, fill = 0)%>%
  dplyr::rename(token = "word")%>%
  select(-Agatha.txt)%>%
  mutate( # remove non-word characters
    token = stringr::str_remove_all(token, "[^[:alpha:] ]"),
         token = stringr::str_remove_all(token, "[[:punct:][:space:]]+"),
 token = na_if(token, "")  )%>%
  filter(!is.na(token))
  
# codes borrowed from https://ladal.edu.au/tutorials/key/key.html

stats_tb2 = texts_df %>%
  dplyr::mutate(text1 = as.numeric(text1),
                text2 = as.numeric(text2)) %>%
  dplyr::mutate(C1 = sum(text1),
                C2 = sum(text2),
                N = C1 + C2) %>%
  dplyr::rowwise() %>%
  dplyr::mutate(R1 = text1+text2,
                R2 = N - R1,
                O11 = text1,
                O12 = R1-O11,
                O21 = C1-O11,
                O22 = C2-O12) %>%
  dplyr::mutate(E11 = (R1 * C1) / N,
                E12 = (R1 * C2) / N,
                E21 = (R2 * C1) / N,
                E22 = (R2 * C2) / N) %>%
  dplyr::select(-text1, -text2)

head(stats_tb2)%>%
  datatable(.,
  filter = "top",
  class = 'cell-border stripe hover compact'
) 


assoc_tb3 = stats_tb2 %>%
  # determine number of rows
  dplyr::mutate(Rws = nrow(.)) %>%   
  # work row-wise
  dplyr::rowwise() %>%
    # calculate fishers' exact test
  dplyr::mutate(p = as.vector(unlist(fisher.test(matrix(c(O11, O12, O21, O22), 
                                                        ncol = 2, byrow = T))[1]))) %>%
# extract descriptives
    dplyr::mutate(ptw_target = O11/C1*1000,
                  ptw_ref = O12/C2*1000) %>%
    
    # extract x2 statistics
    dplyr::mutate(X2 = (O11-E11)^2/E11 + (O12-E12)^2/E12 + (O21-E21)^2/E21 + (O22-E22)^2/E22) %>%
    
    # extract keyness measures
    dplyr::mutate(phi = sqrt((X2 / N)),
                  MI = log2(O11 / E11),
                  t.score = (O11 - E11) / sqrt(O11),
                  PMI = log2( (O11 / N) / ((O11+O12) / N) * 
                                ((O11+O21) / N) ),
                  DeltaP = (O11 / R1) - (O21 / R2),
                  LogOddsRatio = log(((O11 + 0.5) * (O22 + 0.5))  / ( (O12 + 0.5) * (O21 + 0.5) )),
                  G2 = 2 * ((O11+ 0.001) * log((O11+ 0.001) / E11) + (O12+ 0.001) * log((O12+ 0.001) / E12) + O21 * log(O21 / E21) + O22 * log(O22 / E22)),
                  
                  # traditional keyness measures
                  RateRatio = ((O11+ 0.001)/(C1*1000)) / ((O12+ 0.001)/(C2*1000)),
                  RateDifference = (O11/(C1*1000)) - (O12/(C2*1000)),
                  DifferenceCoefficient = RateDifference / sum((O11/(C1*1000)), (O12/(C2*1000))),
                  OddsRatio = ((O11 + 0.5) * (O22 + 0.5))  / ( (O12 + 0.5) * (O21 + 0.5) ),
                  LLR = 2 * (O11 * (log((O11 / E11)))),
                  RDF = abs((O11 / C1) - (O12 / C2)),
                  PDiff = abs(ptw_target - ptw_ref) / ((ptw_target + ptw_ref) / 2) * 100,
                  SignedDKL = sum(ifelse(O11 > 0, O11 * log(O11 / ((O11 + O12) / 2)), 0) - ifelse(O12 > 0, O12 * log(O12 / ((O11 + O12) / 2)), 0))) %>%
    
    # determine Bonferroni corrected significance
    dplyr::mutate(Sig_corrected = dplyr::case_when(p / Rws > .05 ~ "n.s.",
                                                   p / Rws > .01 ~ "p < .05*",
                                                   p / Rws > .001 ~ "p < .01**",
                                                   p / Rws <= .001 ~ "p < .001***",
                                                   T ~ "N.A.")) %>% 
    # round p-value
    dplyr::mutate(p = round(p, 5),
                  type = ifelse(E11 > O11, "antitype", "type"),
                  phi = ifelse(E11 > O11, -phi, phi),
                  G2 = ifelse(E11 > O11, -G2, G2)) %>%
    # filter out non significant results
    dplyr::filter(Sig_corrected != "n.s.") %>%
    # arrange by G2
    dplyr::arrange(-G2) %>%
    # remove superfluous columns
    dplyr::select(-any_of(c("TermCoocFreq", "AllFreq", "NRows", 
                            "R1", "R2", "C1", "C2", "E12", "E21",
                            "E22", "upp", "low", "op", "t.score", "z.score", "Rws"))) %>%
    dplyr::relocate(any_of(c("token", "type", "Sig_corrected", "O11", "O12",
                             "ptw_target", "ptw_ref", "G2",  "RDF", "RateRatio", 
                             "RateDifference", "DifferenceCoefficient", "LLR", "SignedDKL",
                             "PDiff", "LogOddsRatio", "MI", "PMI", "phi", "X2",  
                             "OddsRatio", "DeltaP", "p", "E11", "O21", "O22"))) 
head(assoc_tb3)%>%
  datatable(.,
  filter = "top",
  class = 'cell-border stripe hover compact'
) 

# sort the assoc_tb3 data frame in descending order based on the 'G2' column
assoc_tb3 %>%
    filter(!is.infinite(G2))%>%
    dplyr::arrange(-G2) %>%
    # select the top 20 rows after sorting
    head(20) %>%
    # create a ggplot with 'token' on the x-axis (reordered by 'G2') and 'G2' on the y-axis
    ggplot(aes(x = reorder(token, G2, mean), y = G2)) +
    # add a scatter plot with points representing the 'G2' values
    geom_point() +
    # flip the coordinates to have horizontal points
    coord_flip() +
    # set the theme to a basic white and black theme
    theme_bw() +
    # set the x-axis label to "Token" and y-axis label to "Keyness (G2)"
    labs(x = "Token", y = "Keyness (G2)")

# get top 10 keywords for text 1
top <- assoc_tb3 %>%
    filter(!is.infinite(G2))%>%
    dplyr::ungroup() %>%
    dplyr::slice_head(n = 12)
# get top 10 keywords for text 2
bot <- assoc_tb3 %>%
    dplyr::ungroup() %>%
    dplyr::slice_tail(n = 12)
# combine into table
rbind(top, bot) %>%
    # create a ggplot
    ggplot(aes(x = reorder(token, G2, mean), y = G2, label = G2, fill = type)) +
    # add a bar plot using the 'phi' values
    geom_bar(stat = "identity") +
    # add text labels above the bars with rounded 'phi' values
    geom_text(aes(
        y = ifelse(G2 > 0, G2 - 50, G2 + 50),
        label = round(G2, 1)
    ), color = "white", size = 3) +
    # flip the coordinates to have horizontal bars
    coord_flip() +
    # set the theme to a basic white and black theme
    theme_bw() +
    # remove legend
    theme(legend.position = "none") +
    # define colors
    scale_fill_manual(values = c("orange", "darkgray")) +
    # set the x-axis label to "Token" and y-axis label to "Keyness (G2)"
    labs(title = "Top 10 keywords for text1 and text 2", x = "Keyword", y = "Keyness (G2)")

任务6.3 共现语境分析

任务6.4 搭配提取

基于句子提取搭配

# set options
options(stringsAsFactors = F)
options(scipen = 999)
options(max.print=1000)

# codes were borrowed from https://ladal.edu.au/tutorials/coll/coll.html

sentences =corps.detectives%>%
  # split text into sentences
  tokenizers::tokenize_sentences() %>%
  # unlist sentences
  unlist() %>%
  # remove non-word characters
  stringr::str_replace_all("\\W", " ") %>%
  stringr::str_replace_all("[^[:alnum:] ]", " ") %>%
  # remove superfluous white spaces
  stringr::str_squish() %>%
  # convert to lower case and save in 'sentences' object
  tolower() 

head(sentences)

# tokenize the 'sentences' data using quanteda package
coll_basic = sentences %>%
  quanteda::tokens() %>%
  # create a document-feature matrix (dfm) using quanteda
  quanteda::dfm() %>%
  # create a feature co-occurrence matrix (fcm) without considering trigrams
  quanteda::fcm(tri = FALSE)%>%
  # tidy the data using tidytext package
  tidytext::tidy() %>%
  # rearrange columns for better readability
  dplyr::relocate(term, document, count) %>%
  # rename columns for better interpretation
  dplyr::rename(w1 = 1,
                w2 = 2,
                O11 = 3) 

head(coll_basic)%>%
  datatable(.,
  filter = "top",
  class = 'cell-border stripe hover compact'
) 

# calculate the total number of observations (N)
colldf = coll_basic %>%  
  dplyr::mutate(N = sum(O11)) %>%
  # calculate R1, O12, and R2
  dplyr::group_by(w1) %>%
  dplyr::mutate(R1 = sum(O11),
                O12 = R1 - O11,
                R2 = N - R1) %>%
  dplyr::ungroup(w1) %>%
  # calculate C1, O21, C2, and O22
  dplyr::group_by(w2) %>%
  dplyr::mutate(C1 = sum(O11),
                O21 = C1 - O11,
                C2 = N - C1,
                O22 = R2 - O21) 
head(colldf )%>%
  datatable(.,
  filter = "top",
  class = 'cell-border stripe hover compact'
) 

# reduce and complement data
colldf_redux = colldf %>%
   # determine Term
  dplyr::filter(w1 == "crime",
                # set minimum number of occurrences of w2
                (O11+O21) > 10,
                # set minimum number of co-occurrences of w1 and w2
                O11 > 5)  %>%
  dplyr::rowwise() %>%
  dplyr::mutate(E11 = R1 * C1 / N, 
                E12 = R1 * C2 / N,
                E21 = R2 * C1 / N, 
                E22 = R2 * C2 / N)  
head(colldf_redux )%>%
  datatable(.,
  filter = "top",
  class = 'cell-border stripe hover compact'
) 

# 计算“crime”这个词的搭配
assoc_tb = colldf_redux %>%
  # determine number of rows
  dplyr::mutate(Rws = nrow(.)) %>%
    # work row-wise
    dplyr::rowwise() %>%
    # calculate fishers' exact test
    dplyr::mutate(p = as.vector(unlist(fisher.test(matrix(c(O11, O12, O21, O22), 
                                                        ncol = 2, byrow = T))[1]))) %>%
  # extract AM
    # 1. bias towards top left
    dplyr::mutate(btl_O12 = ifelse(C1 > R1, 0, R1-C1),
                  btl_O11 = ifelse(C1 > R1, R1, R1-btl_O12),
                  btl_O21 = ifelse(C1 > R1, C1-R1, C1-btl_O11),
                  btl_O22 = ifelse(C1 > R1, C2, C2-btl_O12),
                  
    # 2. bias towards top right
                  btr_O11 = 0, 
                  btr_O21 = R1,
                  btr_O12 = C1,
                  btr_O22 = C2-R1) %>%
    
    # 3. calculate AM
    dplyr::mutate(upp = btl_O11/R1,
                  low = btr_O11/R1,
                  op = O11/R1) %>%
    dplyr::mutate(AM = op / upp) %>%
    
    # remove superfluous columns
    dplyr::select(-any_of(c("btr_O21", "btr_O12", "btr_O22", "btl_O12", 
                            "btl_O11", "btl_O21", "btl_O22", "btr_O11"))) %>%
    # extract x2 statistics
    dplyr::mutate(X2 = (O11-E11)^2/E11 + (O12-E12)^2/E12 + (O21-E21)^2/E21 + (O22-E22)^2/E22) %>%
    # extract association measures
    dplyr::mutate(phi = sqrt((X2 / N)),
                Dice = (2 * O11) / (R1 + C1),
                LogDice = log((2 * O11) / (R1 + C1)),
                MI = log2(O11 / E11),
                MS = min((O11/C1), (O11/R1)),
                t.score = (O11 - E11) / sqrt(O11),
                z.score = (O11 - E11) / sqrt(E11),
                PMI = log2( (O11 / N) / ((O11+O12) / N) * 
                              ((O11+O21) / N) ),
                DeltaP12 = (O11 / (O11 + O12)) - (O21 / (O21 + O22)),
                DeltaP21 =  (O11 / (O11 + O21)) - (O21 / (O12 + O22)),
                DP = (O11 / R1) - (O21 / R2),
                LogOddsRatio = log(((O11 + 0.5) * (O22 + 0.5))  / ( (O12 + 0.5) * (O21 + 0.5) )),
                # calculate LL aka G2
                G2 = 2 * (O11 * log(O11 / E11) + O12 * log(O12 / E12) + O21 * log(O21 / E21) + O22 * log(O22 / E22))) %>%

  # determine Bonferroni corrected significance
  dplyr::mutate(Sig_corrected = dplyr::case_when(p / Rws > .05 ~ "n.s.",
                                                 p / Rws > .01 ~ "p < .05*",
                                                 p / Rws > .001 ~ "p < .01**",
                                                 p / Rws <= .001 ~ "p < .001***",
                                                 T ~ "N.A.")) %>%
  
  # round p-value
    dplyr::mutate(p = round(p, 5)) %>%
    # filter out non significant results
    dplyr::filter(Sig_corrected != "n.s.",
                # filter out instances where the w1 and w2 repel each other
                E11 < O11) %>%
    # arrange by DeltaP12 (association measure)
    dplyr::arrange(-DeltaP12) %>%
    # remove superfluous columns
    dplyr::select(-any_of(c("TermCoocFreq", "AllFreq", "NRows", "O12", "O21", 
                            "O22", "R1", "R2", "C1", "C2", "E11", "E12", "E21",
                            "E22", "upp", "low", "op", "Rws"))) 

head(assoc_tb)%>%
  datatable(.,
  filter = "top",
  class = 'cell-border stripe hover compact'
) 

任务6.5 依存句法分析

基于词性的搭配提取

任务6.6 语篇特征分析

文本情感分析

Schweinberger, Martin. 2025. Sentiment Analysis in R. Brisbane: The University of Queensland. url: https://ladal.edu.au/tutorials/sentiment/sentiment.html (Version 2025.04.02).

nrc <- readRDS("data/ch6/nrc.rda")

novels_anno <- tidy_books %>%
    dplyr::group_by(doc_id) %>%
    dplyr::mutate(words = n()) %>%
    dplyr::left_join(nrc) %>%
    dplyr::mutate(
        novel = factor(doc_id),
        sentiment = factor(sentiment)
    )


novels <- novels_anno %>%
    dplyr::group_by(novel) %>%
    dplyr::group_by(novel, sentiment) %>%
    dplyr::summarise(
        sentiment = unique(sentiment),
        sentiment_freq = n(),
        words = unique(words)
    ) %>%
    dplyr::filter(is.na(sentiment) == F) %>%
    dplyr::mutate(percentage = round(sentiment_freq / words * 100, 1))

novels %>%
    dplyr::filter(
        sentiment != "positive",
        sentiment != "negative"
    ) %>%
    ggplot(aes(sentiment, percentage, fill = novel)) +
    geom_bar(
        stat = "identity",
        position = position_dodge()
    ) +
    scale_fill_manual(name = "", values = c("orange", "gray70", "red")) +
    theme_bw() +
    theme(legend.position = "top")

novels %>%
    dplyr::filter(
        sentiment != "positive",
        sentiment != "negative"
    ) %>%
    dplyr::mutate(sentiment = factor(sentiment,
        levels = c(
            "anger", "fear", "disgust", "sadness",
            "surprise", "anticipation", "trust", "joy"
        )
    )) %>%
    ggplot(aes(novel, percentage, fill = sentiment)) +
    geom_bar(stat = "identity", position = position_dodge()) +
    scale_fill_brewer(palette = "RdBu") +
    theme_bw() +
    theme(legend.position = "right") +
    coord_flip()

# Identifying important emotives
novels_impw <- novels_anno %>%
    dplyr::filter(
        !is.na(sentiment),
        sentiment != "anticipation",
        sentiment != "surprise",
        sentiment != "disgust",
        sentiment != "negative",
        sentiment != "sadness",
        sentiment != "positive"
    ) %>%
    dplyr::mutate(sentiment = factor(sentiment, levels = c("anger", "fear", "trust", "joy"))) %>%
    dplyr::group_by(novel) %>%
    dplyr::count(word, sentiment, sort = TRUE) %>%
    dplyr::group_by(novel, sentiment) %>%
    dplyr::top_n(3) %>%
    dplyr::mutate(score = n / sum(n))


novels_impw %>%
    dplyr::group_by(novel) %>%
    slice_max(score, n = 20) %>%
    dplyr::arrange(desc(score)) %>%
    dplyr::ungroup() %>%
    ggplot(aes(x = reorder(word, score), y = score, fill = word)) +
    facet_wrap(novel ~ sentiment, ncol = 4, scales = "free_y") +
    geom_col(show.legend = FALSE) +
    coord_flip() +
    labs(x = "Words")
# Calculating and dispalying polarity
novels %>%
    dplyr::filter(sentiment == "positive" | sentiment == "negative") %>%
    dplyr::select(-percentage, -words) %>%
    dplyr::mutate(
        sentiment_sum = sum(sentiment_freq),
        positive = sentiment_sum - sentiment_freq
    ) %>%
    dplyr::filter(sentiment != "positive") %>%
    dplyr::rename(negative = sentiment_freq) %>%
    dplyr::select(novel, positive, negative) %>%
    dplyr::group_by(novel) %>%
    dplyr::summarise(polarity = positive / negative) %>%
    ggplot(aes(reorder(novel, polarity, mean), polarity, fill = novel)) +
    geom_bar(stat = "identity") +
    geom_text(aes(y = polarity - 0.1, label = round(polarity, 2)),
        color = "white", size = 4
    ) +
    theme_bw() +
    labs(
        y = "Polarity\n(ration of positive to negative emitives)",
        x = ""
    ) +
    coord_cartesian(y = c(0, 2)) +
    scale_y_continuous(
        breaks = seq(0, 2, 1),
        labels = c("more negative", "neutral", "more positive")
    ) +
    theme(legend.position = "none")


# Calculating and dispalying changes in polarity
library(Hmisc)
novels_bin <- novels_anno %>%
    dplyr::group_by(novel) %>%
    dplyr::filter(is.na(sentiment) | sentiment == "negative" | sentiment == "positive") %>%
    dplyr::mutate(
        sentiment = as.character(sentiment),
        sentiment = case_when(
            is.na(sentiment) ~ "0",
            TRUE ~ sentiment
        ),
        sentiment = case_when(
            sentiment == "0" ~ 0,
            sentiment == "positive" ~ 1,
            TRUE ~ -1
        ),
        id = 1:n(),
        index = as.numeric(cut2(id, m = 80))
    ) %>%
    dplyr::group_by(novel, index) %>%
    dplyr::summarize(
        index = unique(index),
        polarity = mean(sentiment)
    )

ggplot(novels_bin, aes(index, polarity)) +
    facet_wrap(vars(novel), scales = "free_x") +
    geom_smooth(se = F, col = "black") +
    theme_bw() +
    labs(
        y = "polarity ratio (mean by bin)",
        x = "index (bin)"
    )

拓展阅读

Exploring the State of the Union Addresses: A Case Study with cleanNLP