Abstract
This document provides the code for exploring how the Japan-returned students were discussed in the Chinese periodical Dongfang zazhi 東方雜誌 (Eastern Miscellany) from 1905 to 1948.
In this paper, we used Dongfang zazhi 東方雜誌 (The Eastern Miscellany)—a major intellectual journal published by the Commercial Press from 1904 to 1948 — as our primary source to examine the debates surrounding students returning from Japan during the late Qing and Republican eras. Our analysis is based on the full-text, semantically enriched version of this journal, available through the Modern China Textual Database. Using this resource, we conducted a corpus-based analysis with the HistText R package developed by the ENP-China project for analyzing large collections of historical Chinese texts.
Simple keyword search (152 documents)
library(histtext)
liuri <- search_documents('"留日"', corpus="dongfangzz")
Pre-trained embeddings are available at : https://sharedocs.huma-num.fr/#/2547/8997/6-HistText/Embeddings
library("fastTextR")
model <- fasttext()
model$load("~/embeddings/toastynews.bin") # load embedding
nearest_neighbors <- ft_nearest_neighbors(model,"留日", k = 30L) # find neighbors
nearest_neighbors <- names(nearest_neighbors)
# Add selected terms to the query (254 results)
liuri_exp <- search_documents('"留日" | "旅日" | "遊日"', corpus="dongfangzz")
Inspect keywords in context using concordance (built-in function included in HistText):
liuri_conc <- search_concordance('"留日" | "旅日" | "遊日"', corpus="dongfangzz") # 335 occurrences
liuri_conc
Select only documents that refer to students (107 documents remain):
# Create a vector with relevant expressions for filtering (study, youth)
xue <- c("學", "靑年")
xue_vec <- paste(xue, sep = "", collapse = "|")
# Search the vector within the target fields (After, Before, Title)
library(dplyr)
liuri_conc <- liuri_conc %>% mutate(xue = str_extract(After, xue_vec))
liuri_conc <- liuri_conc %>% mutate(xue2 = str_extract(Before, xue_vec))
liuri_conc <- liuri_conc %>% mutate(xue3 = str_extract(Title, xue_vec))
liuri_conc <- liuri_conc %>% mutate(match2 = paste0(xue, xue2, xue3))
# Eliminate non matches
liuri_conc_filtered <- liuri_conc %>% filter(!match2 == "NANANA") # 136 occurrences
# Export list of documents for manual checking
write.csv(liuri_conc_filtered, "liuri_conc_to_check.csv")
# Re-import list of manually filtered documents (107)
library(readr)
liuri_filtered <- read_csv("~/data/liuri_filtered.csv")
liuri_meta <- get_search_fields_content(liuri_filtered, corpus="dongfangzz",
search_fields=c(list_search_fields("dongfangzz"),
list_filter_fields("dongfangzz")))
# Extract year of publication (first four digits of document Id)
liuri_meta <- liuri_meta %>%
mutate(Year = str_extract(DocId, "\\d{4}")) %>%
mutate(Year = as.numeric(Year))
liuri_meta <- liuri_meta %>% relocate(Year, .after = DocId)
liuri_meta
Among our results, 34 documents consist of short, unrelated news pieces, where our subject of inquiry appears only as a limited segment within a broader text. We manually curated these documents, extracting only the relevant segments that focus on our object of study (i.e., Japan-returned students):
# Import properly segmented documents (34)
liuri_segmented <- read_delim("~/data/liuri_segmented.csv",
delim = ";", escape_double = FALSE, trim_ws = TRUE)
liuri_segmented <- liuri_segmented %>% dplyr:: rename(text_seg = text)
liuri_segmented
# Join with complete list of documents and remove duplicates
liuri_clean <- left_join(liuri_meta, liuri_segmented)
liuri_clean <- liuri_clean %>% unique()
# Replace missing text in the "text_seg" columns by the original text
liuri_clean <- liuri_clean %>%
mutate(text_seg = ifelse(is.na(text_seg), text, text_seg))
liuri_clean <- liuri_clean %>% relocate(text, .before = text_seg)
# Compute text length
liuri_clean <- liuri_clean %>% mutate(length = nchar(text)) %>% mutate(length_seg = nchar(text_seg))
liuri_clean <- liuri_clean %>% relocate(category, .before = category_strd)
liuri_clean
library(ggplot2)
library(hrbrthemes)
library(viridis)
liuri_clean %>%
ggplot( aes(x=Year)) +
geom_histogram( binwidth=1, fill="#69b3a2", color="#e9ecef", alpha=0.9) +
ggtitle("Bin size = 1") +
theme_ipsum() +
theme(
plot.title = element_text(size=15)
) +
labs(title = "Japan-returned students in 東方雜誌",
x = "Year",
y = "Number of articles")
# Compare with the entire collection using the "stats_date()" function included in HistText
liuri_date <- liuri_year %>% rename(Date = Year)
liuri_stats <- stats_date(liuri_date,"dongfangzz",over_all=TRUE, to_plot = FALSE)
stats_date(liuri_date,"dongfangzz", over_all=TRUE, to_plot = TRUE)
stats_date(liuri_date,"dongfangzz",over_all=TRUE, to_plot = TRUE)
## Found 9 records... Imported 9 records. Simplifying...
liuri_clean %>% group_by(category) %>% count(sort = TRUE) %>% mutate(percent = n/107*100)
liuri_clean %>% group_by(category_strd) %>% count(sort = TRUE) %>% mutate(percent = n/107*100)
liuri_clean %>% group_by(category_sup) %>% count(sort = TRUE) %>% mutate(percent = n/107*100)
Save and export the resulting corpus and statistics as dataframes:
write.csv(liuri_clean, "~/output/liuri_clean.csv")
write.csv(liuri_stats, "~/output/liuri_stats.csv")
Tokenization, the process of segmenting text into words, is a crucial preliminary step for text analysis, including word frequency counting, topic modeling, and sentiment analysis. However, Chinese word segmentation for early 20th-century texts presents unique challenges due to the language’s transition from classical wenyan—the literary language of the imperial administration and literati, where one character typically corresponds to one word—to a national (guoyu) and vernacular language (baihua) closer to modern Mandarin, characterized by more complex forms and diverse word combinations.
For this study, we employed the latest version (2023) of a tokenizer specifically designed for transitional Chinese, the linguistic stage of the period under investigation (Blouin et al., 2023).
# First remove artificial white spaces from the raw text
liuri_to_token <- liuri_clean %>%
mutate(text2 = str_replace_all(text_seg, " ", ""))%>%
mutate(text2 = str_replace_all(text2, " ", "")) %>%
relocate(text2, .after = text_seg)
liuri_to_token <- liuri_to_token %>% mutate(length2 = nchar(text2))
liuri_to_token
# Apply the tokenizer
liuri_tokenized <- cws_on_df(liuri_to_token,
text_column = "text2",
id_column = "DocId",
model = "trftc_shunpao_23:zh:cws",
detailed_output = FALSE,
token_separator = " ",
verbose = TRUE)
liuri_tokenized
Important Note: When the text exceeds 300
characters, the tokenizer automatically splits the document into
multiple lines. To reconstruct the article, we need to merge these text
segments back into a continuous document.
liuri_token_merged <- liuri_tokenized %>%
group_by(DocId) %>%
summarise(text_token =paste(Text,collapse=''))
liuri_token_merged
# join with metadata
liuri_tokenized_meta <- left_join(liuri_token_merged, liuri_clean)
liuri_tokenized_meta <- liuri_tokenized_meta %>% relocate(text_token, .after = text_seg)
liuri_tokenized_meta <- liuri_tokenized_meta %>% mutate(lgth_tok_txt = nchar(text_token))
liuri_tokenized_meta
# save results
write.csv(liuri_tokenized_meta, "~/output/liuri_tokenized_meta.csv")
Examining word frequencies is a useful exploratory step for gaining a preliminary understanding of the vocabulary before conducting more advanced analyses, such as topic modeling or sentiment analysis.
library(tidytext)
liuri_tokens <- liuri_token_merged %>%
unnest_tokens(output = token, input = text_token)
# remove noisy output
liuri_tokens_clean <- liuri_tokens %>%
filter(stringr::str_detect(liuri_tokens$token, "[\\p{Han}]")) %>%
mutate(token = str_replace_all(token, "一", "")) %>%
mutate(token = str_replace_all(token, "[digits:]+", "")) %>%
mutate(token = str_replace_all(token, "〇", "")) %>%
mutate(token = str_replace_all(token, "◎", "")) %>%
mutate(token = str_replace_all(token, "⊙", "")) %>%
mutate(token = str_replace_all(token, "●", "")) %>%
mutate(token = str_replace_all(token, "▲", "")) %>%
mutate(token = str_replace_all(token, "△", ""))
liuri_tokens_clean <- liuri_tokens_clean %>% drop_na(token)
liuri_tokens_clean
# join with metadata
liuri_no_text <- liuri_clean %>% select(c(1:9))
liuri_tokens_meta <- left_join(liuri_tokens_clean, liuri_no_text)
liuri_tokens_meta <- liuri_tokens_meta %>% mutate(tok_length = nchar(token))
# Count tokens
liuri_token_count <- liuri_tokens_meta %>% group_by(token, tok_length) %>% count()
liuri_tokens_meta %>% group_by(token, tok_length) %>% count(sort = TRUE)
# save results
write.csv(liuri_no_text, "~/output/liuri_no_text.csv")
write.csv(liuri_tokens_meta, "~/output/liuri_tokens_meta.csv")
write.csv(liuri_token_count, "~/output/liuri_token_count.csv")
In this research, we applied Structural Topic Modeling (STM), an LDA-based topic modeling method that incorporates metadata—such as publication date and article genre—allowing us to analyze how these factors influence the distribution of topics (Roberts et al. 2019).
library(stm)
library(stminsights)
# Select metadata
meta <- liuri_tokenized_meta %>% transmute(DocId, title, authors, category_sup, Year)
meta <- as.data.frame(meta)
# Create stm corpus object
corpus <- stm::textProcessor(liuri_tokenized_meta$text_token,
metadata = meta,
stem = FALSE,
wordLengths = c(2, Inf),
customstopwords = c("中國", "日本", "可以", "學生", "我們"),
verbose = FALSE)
# Determine threshold for removing words and documents based on frequency
stm::plotRemoved(corpus$documents, lower.thresh = c(0,10, by=5))
out <- stm::prepDocuments(corpus$documents,
corpus$vocab,
corpus$meta)
# Removing 16487 of 22478 terms (16487 of 44936 tokens) due to frequency.
# Removing 1 Documents with No Words.
# Your corpus now has 106 documents, 5991 terms and 28449 tokens.
# Inspect document removed and remove it from the initial dataset with metadata.
corpus$docs.removed
corpus.removed <- liuri_tokenized_meta[-c(97), ]
# Inspect words removed
wordsremoved <- as_tibble(out$words.removed)
wordsremoved
# Build a 5-topic model
mod.5 <- stm::stm(out$documents,
out$vocab, K=5,
prevalence =~ Year + category_sup,
data=out$meta, verbose = FALSE)
# Incorporate year of publication and article category as co-variates
year5 <- stm::estimateEffect(1:5 ~ Year, mod.5, meta=out$meta)
cat5 <- stm::estimateEffect(1:5 ~ category_sup, mod.5, meta=out$meta)
# run_stminsights() # use stm interface for a preliminary exploration (optional)
# Topic proportions and words defining each topic
plot.STM(mod.5,"summary", n = 7)
library(tidytext)
td_beta5 <- tidytext::tidy(mod.5)
options(repr.plot.width=7, repr.plot.height=8, repr.plot.res=100)
td_beta5 %>%
group_by(topic) %>%
top_n(10, beta) %>%
ungroup() %>%
mutate(topic = paste0("Topic ", topic),
term = reorder_within(term, beta, topic)) %>%
ggplot(aes(term, beta, fill = as.factor(topic))) +
geom_col( show.legend = FALSE) +
facet_wrap(~ topic, scales = "free_y") +
coord_flip() +
scale_x_reordered() +
labs(x = NULL, y = expression(beta),
title = "Highest word probabilities for each topic",
subtitle = "Topics related to 留日 in 東方雜誌 (1905-1948)")
# Extract topic proportions by document
topicprop5 <-make.dt(mod.5, meta)
topicprop5
# Compute topic proportions by year
topic5prop <- topicprop5%>% select(c(2:6))
topic_proportion_per_year5 <- aggregate(topic5prop, by = list(Year = topicprop5$Year), mean)
library(reshape)
dfviz5y <- melt(topic_proportion_per_year5, id.vars = "Year")
# Change color palette
library(RColorBrewer)
color_palette5 <- brewer.pal(5, "Set3")
# Plot topic proportions over year
ggplot(dfviz5y, aes(x=Year, y=value, fill=variable)) +
geom_bar(stat = "identity") + ylab("proportion") +
scale_fill_manual(values = color_palette5, name = "Topic") +
theme(axis.text.x = element_text(angle = 90, hjust = 1))+
labs(title="Topics related to 留日 in 東方雜誌",
subtitle = "Topic proportion over time (5-topic)")
This section aims to assign a synthetic variable to each document, representing its topical profile. This variable will be used in the final part of the article, which explores the correlation between document topics, tonal sentiment, and the authors’ biographical profiles. To generate these synthetic variables, we applied Principal Component Analysis (PCA) and Hierarchical Clustering based on article topic proportions.
# Principal Component Analysis (PCA) on Topic Proportions
pca5 <- topicprop5 %>% select(DocId, Topic1, Topic2, Topic3, Topic4, Topic5)
pca5 <- pca5 %>% column_to_rownames("DocId")
library(FactoMineR)
res.PCA<-PCA(pca5,graph=FALSE)
plot.PCA(res.PCA,choix='var',title="PCA Graph of Variables (topic proportions)")
plot.PCA(res.PCA,title="PCA Graph of Individuals (documents)")
# Hierarchical Clustering to cluster documents with similar topical profiles
res.HCPC<-HCPC(res.PCA,nb.clust=5,consol=FALSE,graph=FALSE)
plot.HCPC(res.HCPC,choice='tree',title='Tree Map')
plot.HCPC(res.HCPC,choice='map',draw.tree=FALSE,title='Factor Map')
plot.HCPC(res.HCPC,choice='3D.map',ind.names=FALSE,centers.plot=FALSE,angle=60,title='3D-Tree on Factor Map')
# Extract clusters
pca_clusters <- as.data.frame(res.HCPC$data.clust)
pca_clusters <- rownames_to_column(pca_clusters, "DocId")
pca_clusters <- pca_clusters %>% dplyr:: rename(topicluster = clust)
# Label clusters
pca_clusters <- pca_clusters %>% mutate (topiclabel = fct_recode(topicluster, "T3. Regulations/Petitions" = "1",
"T2. Education/Political Reforms" = "2",
"T4. Diplomacy/Cultural Exchanges" = "3",
"T1. Sino-Japanese Tensions" = "4",
"T5. Science/Society" = "5"))
# join with authors (see dedicated script for how to retrieve the authors)
liuri_pca_id <- pca_clusters %>% select(DocId, topiclabel)
liuri_pca_meta <- left_join(liuri_pca_id, liuri_clean)
liuri_pca_meta <- liuri_pca_meta %>% select(-c(text, text_seg))
liuri_authors_topic <- liuri_pca_meta %>% select(DocId, authors, topiclabel)
liuri_authors_topic <- liuri_authors_topic %>% select(DocId, authors, topiclabel) %>%
mutate(Name = str_replace_all(authors, "\\[", "")) %>%
mutate(Name = str_replace_all(Name, "\\]", ""))%>%
mutate(Name = str_replace_all(Name, "\\'", ""))
liuri_authors_topic <- liuri_authors_topic %>%
separate_rows(Name, sep = ",\\s*")
liuri_authors_topic_unique <- liuri_authors_topic %>% select(authors, Name, topiclabel) %>% drop_na(Name) %>% unique()
liuri_authors_joined2 <- left_join(liuri_author_joined_clean, liuri_authors_topic_unique)
liuri_authors_joined2
# NRC lexicon
nrc_lexic <- read.delim("~/lexicon/Chinese-Traditional-NRC-EmoLex.txt")
nrc_lexic <- nrc_lexic %>% dplyr::rename(token = Chinese.Traditional.Word) %>%
select(token, negative, positive, English.Word) %>%
rename(nrc_negative = negative, nrc_positive = positive)
nrc_lexic
# Chinese (Taiwan) dictionaries
tw_lexic <- read.csv("~/lexicon/opinion_word_utf8.csv", header=FALSE)
# Rename variables
tw_lexic <- tw_lexic %>% rename(token = V1, copeopi = V2, tw_positive = V3, tw_neutral = V4,
tw_negative = V5, tw_nonsent = V6, tw_nonword = V7)
tw_lexic
liuri_senti_nrc <- liuri_tokens_meta %>%
select(DocId, Year, title, category_sup, authors, token, tok_length) %>%
inner_join(nrc_lexic)
liuri_senti_nrc <- liuri_senti_nrc %>% unique()
liuri_senti_tw <- liuri_tokens_meta %>%
select(DocId, Year, title, category_sup, authors, token, tok_length) %>%
inner_join(tw_lexic)
liuri_senti_tw <- liuri_senti_tw %>% unique()
liuri_senti <- full_join(liuri_senti_nrc, liuri_senti_tw)
liuri_senti <- liuri_senti %>% relocate(English.Word, .after = token)
liuri_senti <- liuri_senti %>% filter(tok_length >1)
liuri_senti
# Compute sentiment scores for each token
liuri_senti_token <- liuri_senti %>% select(token, English.Word, tok_length, nrc_negative, nrc_positive,
copeopi, tw_negative, tw_positive, tw_neutral, tw_nonsent, tw_nonword) %>%
mutate(nrc_ratio = (nrc_positive-nrc_negative), tw_ratio = (tw_positive - tw_negative)) %>%
group_by(token) %>% add_tally()
liuri_senti_token <- liuri_senti_token %>% unique()
# Most negative tokens
liuri_senti_token %>% select(token, English.Word, nrc_ratio) %>% arrange((nrc_ratio)) # based on NRC
liuri_senti_token %>% select(token, English.Word, tw_ratio) %>% arrange((tw_ratio)) # based on ANTUSD
liuri_senti_token %>% select(token, English.Word, copeopi) %>% arrange((copeopi)) # based on CopeOpi
# Most positive articles
liuri_senti_token %>% select(token, English.Word, nrc_ratio) %>% arrange(desc(nrc_ratio)) # based on NRC
liuri_senti_token %>% select(token, English.Word, tw_ratio) %>% arrange(desc(tw_ratio)) # based on ANTUSD
liuri_senti_token %>% select(token, English.Word, copeopi) %>% arrange(desc(copeopi)) # based on CopeOpi
# Plot as wordcloud
library(wordcloud2)
nrc_positive <- liuri_senti_token %>%
filter(nrc_positive > 0) %>% # Keep only positive sentiment tokens
select(token, n)
wordcloud2(nrc_positive, size = 4, backgroundColor = "grey", color = "random-light")
library(reshape2)
liuri_senti_token %>% drop_na(nrc_ratio) %>%
mutate(sentiment = ifelse(nrc_ratio > 0, "positive", "negative")) %>%
count(token, sentiment, sort = TRUE) %>%
acast(token ~ sentiment, value.var = "n", fill = 0) %>%
comparison.cloud(colors = c("steelblue", "red"),
max.words = 500, scale = c(2, 2))
# Compute mean sentiment value per document
liuri_senti_docs <- liuri_senti %>% group_by(DocId, Year, title, category_sup, authors) %>%
summarise(across(nrc_negative:tw_nonword, ~ mean(.x, na.rm = TRUE))) %>%
mutate(nrc_ratio = (nrc_positive-nrc_negative)) %>%
mutate(tw_ratio = (tw_positive-tw_negative))
liuri_senti_docs <- liuri_senti_docs %>% relocate(nrc_ratio, .after = nrc_positive) %>%
relocate(tw_ratio, .after = tw_positive)
# Most negative articles
liuri_senti_docs %>% select(DocId, Year, category_sup, title, nrc_ratio) %>% arrange((nrc_ratio)) # based on NRC
liuri_senti_docs %>% select(DocId, Year, category_sup, title, tw_ratio) %>% arrange((tw_ratio)) # based on ANTUSD
liuri_senti_docs %>% select(DocId, Year, category_sup, title, copeopi) %>% arrange((copeopi)) # based on CopeOpi
# Most positive articles
liuri_senti_docs %>% select(DocId, Year, category_sup, title, nrc_ratio) %>% arrange(desc(nrc_ratio)) # based on NRC
liuri_senti_docs %>% select(DocId, Year, category_sup, title, tw_ratio) %>% arrange(desc(tw_ratio)) # based on ANTUSD
liuri_senti_docs %>% select(DocId, Year, category_sup, title, copeopi) %>% arrange(desc(copeopi)) # based on CopeOpi
# Compute sentiment value by category
liuri_senti_category <- liuri_senti_docs %>%
group_by(category_sup) %>%
summarise(across(c("nrc_negative", "nrc_positive", "copeopi", "tw_negative", "tw_positive"), ~ mean(.x, na.rm = TRUE))) %>%
mutate(tw_ratio = (tw_positive-tw_negative)) %>%
mutate(nrc_ratio = (nrc_positive-nrc_negative))
liuri_senti_category <- liuri_senti_category %>% relocate(nrc_ratio, .after = nrc_positive) %>%
relocate(tw_ratio, .after = tw_positive)
liuri_senti_category
# Compute sentiment value over time
liuri_senti_year<- liuri_senti_docs %>%
group_by(Year) %>%
summarise(across(c("nrc_negative", "nrc_positive", "copeopi", "tw_negative", "tw_positive"), ~ mean(.x, na.rm = TRUE))) %>%
mutate(tw_ratio = (tw_positive-tw_negative)) %>%
mutate(nrc_ratio = (nrc_positive-nrc_negative)) %>%
relocate(nrc_ratio, .after = nrc_positive) %>%
relocate(tw_ratio, .after = tw_positive)
p1 <- liuri_senti_year %>%
ggplot() +
geom_col(aes(x = Year, y = nrc_ratio)) +
labs(title = "Sentiment analysis of 留日 in 東方雜誌",
subtitle = "Sentiment value over time",
x = "Year",
y = "Mean value",
caption = "Based on NRC Lexicon (Canada)")
p2 <- liuri_senti_year %>%
ggplot() +
geom_col(aes(x = Year, y = tw_ratio)) +
labs(title = "Sentiment analysis of 留日 in 東方雜誌",
subtitle = "Sentiment value over time",
x = "Year",
y = "Mean value",
caption = "Based on ANTUSD Lexicon (Taiwan)")
p3 <- liuri_senti_year %>%
ggplot() +
geom_col(aes(x = Year, y = copeopi)) +
labs(title = "Sentiment analysis of 留日 in 東方雜誌",
subtitle = "Sentiment value over time",
x = "Year",
y = "Mean value",
caption = "Based on CopeOpi Lexicon (Taiwan)")
library(plotly)
ggplotly(p1)
ggplotly(p2)
ggplotly(p3)
For the next steps, see our script on GitHub for extracting and analyzing biographical data related to the authors. Scripts for building and exploring a corpus centered on liuMei using the same method are also available on GitHub.