---
title: "튜토리얼 5: 텍스트 분석 입문"
subtitle: "assemblykor 패키지로 배우는 데이터 분석"
output:
  html_document:
    toc: true
    toc_float: true
    theme: flatly
    code_folding: show
---

```{r setup, include=FALSE}
knitr::opts_chunk$set(warning = FALSE, message = FALSE, fig.width = 8, fig.height = 5)
library(assemblykor)
library(dplyr)
library(ggplot2)
library(tidyr)
library(stringr)
set_ko_font()
data(bills)
data(speeches)
```

## 이 튜토리얼에서 배울 것

**연구 질문**: 국회의 관심사는 어떻게 변해왔는가?

지금까지 다룬 데이터는 숫자(발의 건수, 재산, 투표 등)였습니다.
하지만 정치학에서 매우 중요한 데이터 유형이 하나 더 있습니다: **텍스트**.
법안 제안이유, 국회 발언, 보도자료 등 텍스트에서 패턴을 발견하는 것이
텍스트 분석입니다.

배울 내용:

- 텍스트 데이터의 전처리 (토큰화, 불용어 제거)
- 단어 빈도 분석과 시각화
- TF-IDF: 특정 그룹의 특징 단어 찾기
- 키워드 추적: 시간에 따른 의제 변화
- 발언 데이터(`speeches`)로 위원회 담론 분석
- 형태소 분석 토큰(`get_speech_tokens()`)으로 공백 토큰화의 한계 넘기

---

## 1. 텍스트를 데이터로 바꾸기

컴퓨터에게 텍스트는 그냥 문자열입니다. 분석하려면 텍스트를 **구조화된 데이터**로
바꿔야 합니다. 가장 기본적인 접근은 **단어 주머니(bag of words)** 모형입니다:
텍스트를 단어들의 집합으로 보고, 각 단어의 빈도를 세는 것입니다.

### 전처리의 흐름

```
원본 텍스트 → 토큰화(단어 쪼개기) → 불용어 제거 → 빈도 계산 → 분석
```

### tidytext 패키지

```{r}
if (!requireNamespace("tidytext", quietly = TRUE)) {
  install.packages("tidytext")
}
library(tidytext)
```

`tidytext`는 텍스트를 tidy data 형식으로 다룹니다.
한 행에 하나의 토큰(단어)이 오는 **긴 형식** 데이터프레임을 만듭니다.

---

## 2. 법안 텍스트 다운로드

`get_bill_texts()`로 60,925건의 법안 제안이유를 다운로드합니다.

```{r download, eval=FALSE}
texts <- get_bill_texts()
```

```{r load-fallback, include=FALSE}
texts <- tryCatch(
  get_bill_texts(),
  error = function(e) {
    message("텍스트 다운로드 실패. 법안 이름을 대신 사용합니다.")
    data.frame(
      bill_id = bills$bill_id,
      propose_reason = bills$bill_name,
      scrape_status = "fallback"
    )
  }
)
```

```{r merge}
bill_text <- bills %>%
  inner_join(texts, by = "bill_id") %>%
  filter(scrape_status == "ok") %>%
  mutate(text_length = nchar(propose_reason))

cat("분석 대상 법안:", nrow(bill_text), "건\n")
```

### 텍스트 길이 분포

제안이유의 길이 자체도 의미 있는 변수입니다.

```{r length-dist}
ggplot(bill_text, aes(x = text_length)) +
  geom_histogram(bins = 80, fill = "steelblue", color = "white") +
  scale_x_log10(labels = scales::comma) +
  labs(
    x = "제안이유 길이 (문자 수, 로그 스케일)",
    y = "빈도",
    title = "법안 제안이유 텍스트 길이 분포"
  ) +
  theme_minimal()
```

---

## 3. 키워드 추적: 시간에 따른 의제 변화

가장 간단하면서도 강력한 텍스트 분석은 **특정 키워드가 얼마나 자주 언급되는지**
추적하는 것입니다.

```{r keyword-trend}
keywords <- c("인공지능", "디지털", "플랫폼", "기후", "탄소", "반도체")

keyword_df <- do.call(rbind, lapply(keywords, function(kw) {
  bill_text %>%
    mutate(year = as.integer(format(propose_date, "%Y"))) %>%
    group_by(year) %>%
    summarise(
      mentions = sum(str_detect(propose_reason, kw), na.rm = TRUE),
      total = n(),
      .groups = "drop"
    ) %>%
    mutate(keyword = kw, pct = mentions / total * 100)
}))

ggplot(keyword_df, aes(x = year, y = pct, color = keyword)) +
  geom_line(linewidth = 1) +
  geom_point(size = 1.5) +
  labs(
    x = "연도", y = "언급 비율 (%)", color = "키워드",
    title = "법안 제안이유 속 키워드 추이 (20-22대)"
  ) +
  theme_minimal()
```

어떤 키워드가 급등했나요? 그 시기에 어떤 사회적 이슈가 있었는지
떠올려보세요.

> **주의**: 키워드 빈도는 실제 정책 관심을 반영하지만,
> 같은 단어라도 맥락에 따라 다른 의미로 쓰일 수 있습니다.
> 키워드 분석은 탐색적 도구이지, 그 자체로 결론을 내리기는 어렵습니다.

---

## 4. 토큰화와 불용어 제거

텍스트를 단어 단위로 쪼개는 것을 **토큰화(tokenization)**라 합니다.

한국어의 특수성: 한국어는 조사가 단어에 붙어 있어서 ("법률을", "법률이", "법률에")
같은 단어가 다른 형태로 나타납니다. 완벽한 처리를 위해서는 형태소 분석기가
필요하지만, 간단한 분석에서는 **공백 기반 토큰화**도 유용합니다.

```{r tokenize}
# 22대 법안 중 최대 5,000건 표본
sample_bills <- bill_text %>%
  filter(assembly == 22) %>%
  slice_sample(n = 5000)

# 공백 기반 토큰화
tokens <- sample_bills %>%
  select(bill_id, committee, propose_reason) %>%
  unnest_tokens(word, propose_reason, token = "words")

cat("토큰 수:", nrow(tokens), "\n")
```

### 불용어 제거

토큰화 결과를 보면, "의", "에", "를" 같은 조사와 기능어가 상위를 차지합니다.
이런 단어를 **불용어(stopwords)**라 하고, 분석에서 제거합니다.

```{r stopwords}
ko_stopwords <- c(
  "의", "에", "를", "을", "이", "가", "은", "는", "및", "등",
  "한", "하는", "에서", "으로", "로", "위한", "대한", "따른",
  "그", "수", "것", "있는", "하여", "또는", "관한", "위하여",
  "경우", "있다", "해당", "필요", "현행", "대하여", "때문에",
  "하고", "대해", "이를", "따라", "하기", "하며", "있으며",
  "규정", "관련", "정하고", "마련", "있도록", "위해"
)

tokens_clean <- tokens %>%
  filter(!word %in% ko_stopwords, nchar(word) >= 2)
```

### 가장 자주 등장하는 단어

```{r top-words}
top20 <- tokens_clean %>%
  count(word, sort = TRUE) %>%
  head(20)

ggplot(top20, aes(x = reorder(word, n), y = n)) +
  geom_col(fill = "coral") +
  coord_flip() +
  labs(
    x = NULL, y = "빈도",
    title = "가장 많이 등장하는 단어 (22대 법안)"
  ) +
  theme_minimal()
```

---

## 5. TF-IDF: 위원회별 특징 단어

단순 빈도만으로는 "모든 법안에서 자주 쓰이는 일반적 단어"와
"특정 위원회에서만 자주 쓰이는 특징적 단어"를 구분하기 어렵습니다.

**TF-IDF (Term Frequency - Inverse Document Frequency)**는 이 문제를 해결합니다.

- **TF**: 특정 문서(여기서는 위원회)에서 해당 단어가 얼마나 자주 나타나는가
- **IDF**: 해당 단어가 전체 문서 중 얼마나 **드물게** 나타나는가
- **TF-IDF = TF x IDF**: 특정 문서에서 자주 나타나면서, 전체적으로는 드문 단어 = **특징 단어**

```{r tfidf}
committee_words <- tokens_clean %>%
  count(committee, word, sort = TRUE)

committee_tfidf <- committee_words %>%
  bind_tf_idf(word, committee, n)
```

### 위원회별 특징 단어 비교

```{r tfidf-plot, fig.height=7}
top_committees <- c("보건복지위원회", "국토교통위원회",
                    "과학기술정보방송통신위원회", "환경노동위원회")

committee_tfidf %>%
  filter(committee %in% top_committees) %>%
  group_by(committee) %>%
  slice_max(tf_idf, n = 8) %>%
  ggplot(aes(x = reorder_within(word, tf_idf, committee), y = tf_idf)) +
  geom_col(fill = "steelblue") +
  facet_wrap(~ committee, scales = "free_y", ncol = 2) +
  coord_flip() +
  scale_x_reordered() +
  labs(
    x = NULL, y = "TF-IDF",
    title = "위원회별 특징 단어"
  ) +
  theme_minimal() +
  theme(strip.text = element_text(size = 10))
```

각 위원회의 특징 단어가 그 위원회의 관할 영역을 잘 반영하는지 확인해보세요.

---

## 6. 발언 데이터로 담론 분석

`speeches` 데이터는 22대 과학기술정보방송통신위원회의 발언 전문입니다.
법안 텍스트와는 다른 종류의 분석이 가능합니다.

```{r speeches-overview}
cat("발언 수:", nrow(speeches), "\n")
cat("발언자 역할:\n")
table(speeches$role) %>% sort(decreasing = TRUE) %>% head(8)
```

### 의원 vs 관료의 발언 비교

```{r role-compare}
role_tokens <- speeches %>%
  filter(role %in% c("legislator", "minister", "vice_minister")) %>%
  mutate(role_kr = case_match(role,
    "legislator" ~ "의원",
    "minister" ~ "장관",
    "vice_minister" ~ "차관"
  )) %>%
  select(role_kr, speech) %>%
  unnest_tokens(word, speech, token = "words") %>%
  filter(!word %in% ko_stopwords, nchar(word) >= 2)

role_tfidf <- role_tokens %>%
  count(role_kr, word) %>%
  bind_tf_idf(word, role_kr, n)

role_tfidf %>%
  group_by(role_kr) %>%
  slice_max(tf_idf, n = 10) %>%
  ggplot(aes(x = reorder_within(word, tf_idf, role_kr), y = tf_idf)) +
  geom_col(fill = "steelblue") +
  facet_wrap(~ role_kr, scales = "free") +
  coord_flip() +
  scale_x_reordered() +
  labs(
    x = NULL, y = "TF-IDF",
    title = "발언자 유형별 특징 단어 (22대 과방위)"
  ) +
  theme_minimal()
```

의원과 관료가 같은 위원회에서 어떤 다른 언어를 사용하는지 비교해보세요.

### 특정 키워드 발언 패턴

AI 관련 발언이 시간에 따라 어떻게 변하는지 살펴봅시다.

```{r ai-speeches}
ai_speeches <- speeches %>%
  filter(str_detect(speech, "인공지능|AI|에이아이")) %>%
  mutate(month = format(date, "%Y-%m"))

ai_trend <- ai_speeches %>%
  count(month) %>%
  mutate(month = as.Date(paste0(month, "-01")))

ggplot(ai_trend, aes(x = month, y = n)) +
  geom_col(fill = "coral") +
  labs(
    x = NULL, y = "AI 관련 발언 수",
    title = "과학기술정보방송통신위원회 AI 관련 발언 추이 (22대)"
  ) +
  theme_minimal()
```

---

## 7. 형태소 분석: 조사와 어미 넘어서기

지금까지의 공백 토큰화에는 한계가 있습니다. 한국어는 교착어라서
"국민이", "국민을", "국민은"이 전부 다른 토큰으로 집계됩니다.
**형태소 분석**은 단어를 의미 단위로 쪼개고 조사와 어미를 분리해서
이 문제를 해결합니다.

형태소 분석기를 직접 설치하는 것은 초심자에게 쉽지 않으므로,
`assemblykor`는 Kiwi 형태소 분석기로 미리 처리한 토큰을 제공합니다.
`speeches`의 15,843개 발언 전체가 분석되어 있습니다.

```{r download-tokens, eval=FALSE}
tokens_morph <- get_speech_tokens()
```

```{r load-tokens, include=FALSE}
tokens_morph <- get_speech_tokens()
morph_ok <- !is.null(tokens_morph)
if (!morph_ok) message("토큰 다운로드 실패. 형태소 섹션을 건너뜁니다.")
```

```{r morph-overview, eval=morph_ok}
head(tokens_morph)
table(tokens_morph$pos)
```

내용 형태소만 포함되어 있습니다: 명사(NNG/NNP), 동사(VV), 형용사(VA),
부사(MAG), 외국어(SL, 예: "AI"). 동사와 형용사는 사전형("-다")으로
**표제어 추출(lemmatization)**되어 있어서, "말했습니다", "말하고",
"말하면"이 모두 "말하다" 하나로 집계됩니다.

### 공백 토큰화 vs 형태소 분석

같은 의원 발언을 두 방법으로 토큰화해 상위 단어를 비교해 봅시다.

```{r morph-compare, eval=morph_ok, fig.height=6}
# 발언자 메타데이터 결합: date + speech_order가 발언 식별자
morph_leg <- tokens_morph %>%
  inner_join(
    speeches %>% select(date, speech_order, role),
    by = c("date", "speech_order")
  ) %>%
  filter(role == "legislator")

ws_top <- speeches %>%
  filter(role == "legislator") %>%
  unnest_tokens(word, speech, token = "words") %>%
  filter(!word %in% ko_stopwords, nchar(word) >= 2) %>%
  count(word, sort = TRUE) %>%
  head(15) %>%
  mutate(method = "공백 토큰화")

morph_top <- morph_leg %>%
  filter(pos %in% c("NNG", "NNP")) %>%
  count(word = token, sort = TRUE) %>%
  head(15) %>%
  mutate(method = "형태소 분석 (명사)")

bind_rows(ws_top, morph_top) %>%
  ggplot(aes(x = reorder_within(word, n, method), y = n)) +
  geom_col(fill = "steelblue") +
  facet_wrap(~ method, scales = "free") +
  coord_flip() +
  scale_x_reordered() +
  labs(
    x = NULL, y = "빈도",
    title = "의원 발언 상위 단어: 토큰화 방법 비교 (22대 과방위)"
  ) +
  theme_minimal()
```

공백 쪽에는 조사가 붙은 형태와 기능어가 섞여 있지만, 형태소 쪽은
불용어 사전 없이도 깨끗한 명사 목록이 나옵니다. 수작업 불용어
사전(`ko_stopwords`)이 품사 정보로 대체된 것입니다.

### 품사를 활용한 분석

품사 태그가 있으면 질문을 더 정교하게 던질 수 있습니다.
예를 들어, 의원과 장관은 어떤 **동사**를 다르게 쓸까요?

```{r morph-verbs, eval=morph_ok, fig.height=5}
verb_tfidf <- tokens_morph %>%
  inner_join(
    speeches %>% select(date, speech_order, role),
    by = c("date", "speech_order")
  ) %>%
  filter(role %in% c("legislator", "minister"), pos == "VV") %>%
  mutate(role_kr = ifelse(role == "legislator", "의원", "장관")) %>%
  count(role_kr, token) %>%
  bind_tf_idf(token, role_kr, n)

verb_tfidf %>%
  group_by(role_kr) %>%
  slice_max(tf_idf, n = 10) %>%
  ggplot(aes(x = reorder_within(token, tf_idf, role_kr), y = tf_idf)) +
  geom_col(fill = "coral") +
  facet_wrap(~ role_kr, scales = "free") +
  coord_flip() +
  scale_x_reordered() +
  labs(
    x = NULL, y = "TF-IDF",
    title = "의원 vs 장관: 특징적으로 쓰는 동사 (22대 과방위)"
  ) +
  theme_minimal()
```

질문하는 쪽(의원)과 답변하는 쪽(장관)의 언어가 어떻게 다른지
동사 수준에서 확인할 수 있습니다.

> **직접 형태소 분석을 하고 싶다면**: 이 토큰은 Python의 `kiwipiepy`
> 패키지로 생성했습니다 (스크립트: 패키지 저장소의
> `data-raw/tokenize_speeches.py`). R에서 직접 분석하려면 Kiwi의 R
> 버전인 `elbird` 패키지가 있지만, 별도 빌드 도구 설치가 필요합니다.

---

## 8. 시기별 비교: 대수 간 텍스트 변화

```{r temporal}
era_tokens <- tokens_clean %>%
  inner_join(sample_bills %>% select(bill_id, assembly), by = "bill_id") %>%
  filter(assembly %in% c(20, 22)) %>%
  mutate(era = paste0(assembly, "대"))

era_tfidf <- era_tokens %>%
  count(era, word, sort = TRUE) %>%
  bind_tf_idf(word, era, n)

era_tfidf %>%
  group_by(era) %>%
  slice_max(tf_idf, n = 10) %>%
  ggplot(aes(x = reorder_within(word, tf_idf, era), y = tf_idf, fill = era)) +
  geom_col(show.legend = FALSE) +
  facet_wrap(~ era, scales = "free") +
  coord_flip() +
  scale_x_reordered() +
  scale_fill_manual(values = c("20대" = "steelblue", "22대" = "coral")) +
  labs(
    x = NULL, y = "TF-IDF",
    title = "20대 vs 22대 국회: 특징 단어 비교"
  ) +
  theme_minimal()
```

6년 사이에 국회의 관심사가 어떻게 바뀌었는지 단어 수준에서 확인할 수 있습니다.

---

## 정리: 오늘 배운 것

| 기법 | 용도 | 핵심 함수 |
|------|------|----------|
| 키워드 추적 | 특정 단어의 시간적 추이 | `str_detect()` |
| 토큰화 | 텍스트를 단어 단위로 분해 | `unnest_tokens()` |
| 불용어 제거 | 의미 없는 기능어 필터링 | `filter(!word %in% ...)` |
| 단어 빈도 | 가장 많이 쓰이는 단어 | `count(word)` |
| TF-IDF | 그룹별 특징 단어 발견 | `bind_tf_idf()` |
| 형태소 분석 | 조사·어미 분리, 표제어 추출, 품사 필터 | `get_speech_tokens()` |

> **참고**: 공백 기반 토큰화는 한국어의 교착어 특성을 무시하므로
> 빠른 탐색용으로만 적합합니다. 본격적인 분석에서는 7절처럼 형태소
> 분석 결과를 사용하세요.

다음 튜토리얼에서는 **공동발의 네트워크**를 분석합니다.
누가 누구와 함께 법안을 만드는지, 그 관계의 구조를 탐색합니다.

---

## 연습 문제

### 문제 1 (기초)

"부동산", "주택", "임대"가 포함된 법안의 비율을 대수별로 계산하시오.

```{r eval=FALSE}
# 여기에 코드를 작성하세요

```

<details>
<summary>정답 보기</summary>
```{r}
bill_text %>%
  mutate(housing = str_detect(propose_reason, "부동산|주택|임대")) %>%
  group_by(assembly) %>%
  summarise(
    total = n(),
    n_housing = sum(housing, na.rm = TRUE),
    pct = round(n_housing / total * 100, 1)
  )
```
</details>

### 문제 2 (기초)

`speeches` 데이터에서 가장 많이 발언한 의원 상위 10명을 찾고,
발언 횟수를 막대그래프로 그리시오.
(힌트: `role == "legislator"`로 필터링)

```{r eval=FALSE}
# 여기에 코드를 작성하세요

```

<details>
<summary>정답 보기</summary>
```{r}
speeches %>%
  filter(role == "legislator") %>%
  count(speaker_name, sort = TRUE) %>%
  head(10) %>%
  ggplot(aes(x = reorder(speaker_name, n), y = n)) +
  geom_col(fill = "steelblue") +
  coord_flip() +
  labs(x = NULL, y = "발언 횟수",
       title = "22대 과방위에서 가장 많이 발언한 의원 (상위 10명)") +
  theme_minimal()
```
</details>

### 문제 3 (응용)

두 정당(국민의힘 vs 더불어민주당) 의원의 법안 텍스트를 비교하시오.
`proposer_id`를 `legislators`와 연결하여 정당 정보를 가져온 뒤,
정당별 TF-IDF 상위 단어를 비교하시오.

```{r eval=FALSE}
# 여기에 코드를 작성하세요

```

<details>
<summary>정답 보기</summary>
```{r}
data(legislators)

party_bills <- sample_bills %>%
  inner_join(
    legislators %>% filter(assembly == 22) %>% select(member_id, party),
    by = c("proposer_id" = "member_id")
  ) %>%
  filter(party %in% c("국민의힘", "더불어민주당"))

party_tokens <- party_bills %>%
  select(bill_id, party, propose_reason) %>%
  unnest_tokens(word, propose_reason) %>%
  filter(!word %in% ko_stopwords, nchar(word) >= 2)

party_tfidf <- party_tokens %>%
  count(party, word) %>%
  bind_tf_idf(word, party, n)

party_tfidf %>%
  group_by(party) %>%
  slice_max(tf_idf, n = 10) %>%
  ggplot(aes(x = reorder_within(word, tf_idf, party), y = tf_idf, fill = party)) +
  geom_col(show.legend = FALSE) +
  facet_wrap(~ party, scales = "free") +
  coord_flip() + scale_x_reordered() +
  scale_fill_manual(values = c("국민의힘" = "#E61E2B", "더불어민주당" = "#004EA2")) +
  labs(x = NULL, y = "TF-IDF", title = "정당별 법안 특징 단어") +
  theme_minimal()
```
</details>

### 문제 4 (도전)

`speeches` 데이터에서 의원(`legislator`)과 관료(`minister`, `vice_minister`)의
**평균 발언 길이**를 비교하시오. 발언 길이가 역할에 따라 다른 이유는 무엇일까?

```{r eval=FALSE}
# 여기에 코드를 작성하세요

```

<details>
<summary>정답 보기</summary>
```{r}
speeches %>%
  filter(role %in% c("legislator", "minister", "vice_minister")) %>%
  mutate(
    speech_length = nchar(speech),
    role_kr = case_match(role,
      "legislator" ~ "의원",
      "minister" ~ "장관",
      "vice_minister" ~ "차관"
    )
  ) %>%
  group_by(role_kr) %>%
  summarise(
    n = n(),
    mean_length = round(mean(speech_length)),
    median_length = median(speech_length)
  )
```
의원은 주로 짧은 질문을 하고, 관료는 긴 답변을 하기 때문에
발언 길이 차이가 나타납니다. 위원회 질의-응답 구조를 반영합니다.
</details>
