이 글은 제출한 검증보고서의 결과물 도출 과정을 제시하고자 작성하였다.
0. 연구 질문을 구현하기 위한 분석 구조
실제 연구에서 중요한 부분은 개별 문법이 아니라 연구 질문을 분석 가능한 구조로 구현하는 과정이라고 생각한다.
같은 Python과 같은 NLP 라이브러리를 사용하더라도,
무엇을 입력하고,
어떤 기준으로 전처리하며,
어떤 결과를 비교할 것인지는 연구 질문에 따라 달라진다.
따라서 이 연구에서 작성한 코드는 새로운 알고리즘을 만드는 것이 아니라,
역사학의 연구 질문을 디지털 분석이 가능한 형태로 재구성하는 과정에 가까웠다.
1. 법률 기록을 시계열 구조로 변환하다
첫 번째 파이프라인은 법률 기록을 연도별 구조로 변환하는 작업이었다.
원자료는 개별 판결 기록의 집합이지만,
연구에서는 이를 연도별 변화라는 형태로 다시 구성하였다.
이를 위해
- 판결 날짜를 연도로 변환하고
- 죄명을 동일한 기준으로 표준화한 뒤
- 연도별 기소 건수를 집계하는 구조를 설계하였다.
이 과정은 단순한 데이터 정리가 아니라,
후속 분석에서 신문 자료와 비교하기 위한 기준 데이터를 만드는 단계였다.
또한 역사적 사건(3·1운동, 광주학생항일운동 등)을 동일한 시계열 위에 함께 표시하여
데이터 변화와 역사적 사건을 함께 확인할 수 있도록 구성하였다.
즉 코드의 목적은 그래프를 그리는 것이 아니라,
기록을 시간축 위에서 비교 가능한 형태로 변환하는 것이었다.
예> [그림 2] 연도별 보안법·치안유지법 기소 시계열
legal_temporal_coding_pipeline.py
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import re
# 목적 : 집행원부 데이터 기반으로 연도별 기소 패턴, 죄명 표준화, 역사 이벤트 오버레이 확인
-중략-
# 3. 죄명 표준화 (robust version)
def standardize_crime(text):
if pd.isna(text):
return "unknown"
text = str(text).replace(" ", "")
patterns = {
"치안유지법": r"치안|유지",
"보안법": r"보안",
"출판법": r"출판",
"소요죄": r"소요",
"사상범": r"사상",
"제령위반": r"제령|포고"
}
for label, pattern in patterns.items():
if re.search(pattern, text):
return label
return "other"
df["crime_std"] = df["crime"].apply(standardize_crime)
-생략-
2. 토픽 모델링보다 먼저 확인한 것
초기에는 형태소 분석 이후 곧바로 토픽 모델링을 수행하였다.
https://uourstar.tistory.com/66
디지털 인문학 공부 : 근대 한국어 토크나이저 적용 및 전처리 학습 기록 (26.01-02 기록)
블로그의 토크나이저 포스팅(2026년 1~2월)을 보고서 형식으로 다듬어 정리했습니다. 관련 원본 링크는 하단에 제시했으며,이 포스팅의 내용과 6월의 토크나이저 활용 연구 내용이 '검증 기록'으
uourstar.tistory.com
[그림 3] 초기 토픽 모델링 결과(기능어 중심 토픽)
하지만 연구를 진행하면서
토픽 모델링보다 먼저 확인해야 하는 것이 있다는 사실을 알게 되었다.
바로
입력 데이터 자체가 분석 가능한 상태인가였다.
그래서 이후 코드 구조도 바뀌었다.
토픽 모델을 실행하기 전에
- 토큰화 결과
- 토큰 개수
- 핵심 개념어 유지 여부
- 중간 CSV
를 먼저 확인하도록 분석 순서를 다시 설계하였다.
즉
토픽 모델링
↓
결과 확인
에서
토큰 검증
↓
중간 산출물 확인
↓
토픽 모델링
으로 연구 절차 자체가 바뀌었다.
이 변화는 이후 검증보고서에서도 그대로 이어진다.
[그림 5] 토크나이저 출력 형식 및 후처리 디버깅 결과
코드 예시 10_subword_topic_modeling_debug0623.ver.py >
(여러 코드 중 디버깅 코드만 간단히 소개하겠다.)
import os
import pandas as pd
from kiwipiepy import Kiwi
from kiwipiepy.sw_tokenizer import SwTokenizer
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.decomposition import LatentDirichletAllocation
TOKENIZER_JSON = "ModernKoreanSubword/tokenizer/241112_vo32000_tokenizer.json"
kiwi = Kiwi()
tokenizer = SwTokenizer(TOKENIZER_JSON, kiwi=kiwi)
# ===== 디버그용 전역 변수 =====
debug_once = True
def historical_content_word_tokenizer(sentence):
global debug_once
if not isinstance(sentence, str) or not sentence.strip():
return []
vocab_list = tokenizer.encode(sentence)
refined_tokens = []
with_tag = 0
without_tag = 0
tag_examples = []
no_tag_examples = []
for i in vocab_list:
token = tokenizer.id2vocab[i].lstrip("##")
if "/" in token:
with_tag += 1
if len(tag_examples) < 10:
tag_examples.append(token)
word, tag = token.rsplit("/", 1)
# 조사 / 어미 / 접사 제거
if tag.startswith(("J", "E", "X")):
continue
# 의미 없는 용언 제거
if tag.startswith("V") and word in ["하", "있", "되", "없", "보"]:
continue
# 핵심 콘텐츠어만 유지
if tag.startswith("NN") or tag.startswith("V") or tag in ["SH", "SL"]:
if len(word) > 1:
refined_tokens.append(word)
else:
without_tag += 1
if len(no_tag_examples) < 10:
no_tag_examples.append(token)
if len(token) > 1:
refined_tokens.append(token)
-생략-
3. 프레임 분석도 "사전 구축"부터 시작했다
프레임 분석 역시
기성 모델을 그대로 사용하는 대신
연구 질문에 맞게 사용할 범주와 어휘 목록을 정리하였다.
예를 들어
저항,
통제,
범죄와 같은 범주는
분석 모델이 자동으로 만들어주는 것이 아니라,
관련 어휘를 직접 정의하고,
그 분포를 비교하는 방식으로 구현하였다.
코드에서는
각 토픽에서 관련 개념어가 얼마나 등장하는지를 계산하여
프레임 강도를 비교하도록 설계하였다.
이는
특정 알고리즘을 개발한 것이 아니라,
역사학의 범주를 디지털 분석이 가능한 형태로 번역한 작업이라고 볼 수 있다.
[그림6] 토픽별 저항(Resistance) vs 통제/범죄(Crime) 프레임 강도 및 히트맵
코드 예시 03_scv_frame_index_embedding_based.py >
참고로, 불용어 / 키워드 / Vectorizer 수치 등은 내가 임의로 설정했고,
적절한 불용어와 Vectorizer 설정에 대해서 계속 검토 중이다.
(올해 1월부터 관련 공부를 했지만, 아직도 키워드 & 수치 설정을 어떻게 해야 할지 막막하다.
원전 담론 연구계획서를 쓸 때는 관련 방법론 논문도 많았기에 그걸 답습할 수 있었지만,
일제강점기는 어떻게 접근해야 할지 모르겠기에 보편적인 수치를 그대로 반영했을 뿐이다.)
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.decomposition import LatentDirichletAllocation
-중략-
# 2. 불용어 리스트
stop_words = ["하고", "그러나", "에서", "있는", "하는", "그리고", "나는", "그럼", "또한", "위한", "대한", "위해", "까지", "에게", "이번", "지난", "나타", "하다", "것이", "그", "이", "더"]
# 3. Vectorizer
# Adjusting max_df and min_df to be slightly more permissive for a smaller sample to get more diverse topics.
# Use max_df=0.95 for small samples, min_df=1 if there are very few documents.
# For the user's provided sample dataset (8 rows), these might need care. Let's use 1 and 0.95.
vectorizer = CountVectorizer(max_df=0.95, min_df=1, max_features=3000, stop_words=stop_words)
try:
X = vectorizer.fit_transform(df[target_col].fillna(""))
terms = vectorizer.get_feature_names_out()
except ValueError:
# Handle the case where no terms are left after filtering (especially with tiny sample data).
# Re-vectorize with more lenient params.
vectorizer = CountVectorizer(max_df=1.0, min_df=1, max_features=3000)
X = vectorizer.fit_transform(df[target_col].fillna(""))
terms = vectorizer.get_feature_names_out()
# 4. LDA 학습)
-중략-
# 5. 키워드 설정 (Keep user's lists)
resistance_words = ["독립", "교육", "학교", "계몽", "운동", "문화", "청년", "민족", "獨立", "敎育", "啓蒙", "文化", "民族"]
crime_words = ["검거", "체포", "위반", "사상범", "치안", "불온", "소요", "폭동", "逮捕", "檢擧", "違反", "思想犯", "治安", "不穩"]
# 6. 점수 계산 (Maintain user's logic)
results = []
for i, topic in enumerate(topic_word):
r_score = sum(topic[j] for j, term in enumerate(terms) if any(k in term for k in resistance_words))
c_score = sum(topic[j] for j, term in enumerate(terms) if any(k in term for k in crime_words))
# The original code only got 5 and didn't clearly display them.
# A proper topic label is also a new requirement.
top_indices = topic.argsort()[:-7:-1] # 6개 키워드 추출
top_keywords = [terms[idx] for idx in top_indices if terms[idx] not in stop_words]
keywords_str = ", ".join(top_keywords[:6]) # ensure max 6
# 7. Add a label to each topic based on keywords (NEW REQUIREMENT)
if r_score > c_score * 1.5:
topic_label = "저항 중심 토픽"
elif c_score > r_score * 1.5:
topic_label = "통제/범죄 중심 토픽"
elif (r_score > 0.0001 or c_score > 0.0001) and abs(r_score - c_score) / (r_score + c_score) < 0.2:
topic_label = "혼합 프레임 토픽"
elif r_score > 0 or c_score > 0:
topic_label = "기타 주제 토픽 (일부 프레임)"
else:
topic_label = "일반 주제 토픽 (프레임 미검출)"
4. 서로 다른 기록 체계를 비교하기 위한 구조
마지막 단계에서는
신문과 법률 기록을 하나의 분석 구조에서 비교하였다.
두 자료는 작성 목적도,
기록 방식도,
사용하는 언어도 다르다.
따라서 직접 비교하기보다
각 자료의 분포를 먼저 계산한 뒤,
그 차이를 정량적으로 비교하도록 설계하였다.
이 과정에서는
분포 비교를 위한 거리 지표도 함께 구현하였지만,
검증 과정에서 기준 분포의 한계를 확인했기 때문에,
연구 결과의 근거로 사용하지는 않았다.
오히려 비교가 가능한 분석 구조를 설계하는 것이
이 단계의 핵심 목적이었다.
[그림7] 언론 vs 법률 데이터 분포 비교 (JS Divergence: 0.0322)
코드 예시 04_scv_cross_archive_divergence.py>
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.decomposition import LatentDirichletAllocation
import os
# 0. 설정
plt.rcParams['font.family'] = 'Malgun Gothic'
if not os.path.exists("output"):
os.makedirs("output")
# 1. 데이터 로드 및 LDA 학습
df_news = pd.read_excel("data/chosun_1920_1945_sample.xlsx")
target_col = "clean" if "clean" in df_news.columns else "text"
df_news = df_news.dropna(subset=[target_col])
vectorizer = CountVectorizer(max_df=0.9, min_df=2, max_features=3000)
X = vectorizer.fit_transform(df_news[target_col].fillna(""))
lda = LatentDirichletAllocation(n_components=8, random_state=42, max_iter=20)
lda.fit(X)
# 2. 토픽 할당 및 이름 매핑
df_news["topic_id"] = np.argmax(lda.transform(X), axis=1)
topic_names = {0: "민족교육", 1: "사건보도", 2: "치안통제", 3: "문화행사",
4: "청년운동", 5: "해외소식", 6: "독립운동", 7: "일상기록"}
df_news["topic_name"] = df_news["topic_id"].map(topic_names)
# 3. 분포 계산
event_keywords = ["학생", "운동", "동맹", "시위"]
news_event = df_news[df_news[target_col].astype(str).str.contains("|".join(event_keywords))]
news_dist = news_event["topic_name"].value_counts(normalize=True).reindex(topic_names.values(), fill_value=0)
try:
df_legal = pd.read_excel("data/legal_records.xlsx")
legal_dist = df_legal["crime_std"].value_counts(normalize=True).reindex(topic_names.values(), fill_value=0)
except:
legal_dist = pd.Series([0.1]*8, index=topic_names.values())
# 4. JS Divergence 계산 (복사본 생성으로 에러 해결)
def js_divergence(p, q):
p_vec = p.values.astype(float).copy() # .copy() 추가로 읽기 전용 해결
q_vec = q.values.astype(float).copy()
p_vec /= (p_vec.sum() + 1e-9)
q_vec /= (q_vec.sum() + 1e-9)
m = 0.5 * (p_vec + q_vec)
return 0.5 * (np.sum(p_vec * np.log(p_vec/m + 1e-9)) + np.sum(q_vec * np.log(q_vec/m + 1e-9)))
div = js_divergence(legal_dist, news_dist)
-이후 내용 생략-
5. 결국 코드는 연구 질문을 구현하는 과정이었다
처음에는
Python을 배우고,
라이브러리를 익히는 것이 연구의 핵심이라고 생각했다.
하지만 실제 구현을 반복하면서
중요한 것은 프로그래밍 기술 자체가 아니라는 점을 점차 확인하게 되었다.
같은 라이브러리를 사용해도
어떤 연구 질문을 가지고,
어떤 데이터를 만들며,
어떤 비교 구조를 설계하느냐에 따라
전혀 다른 연구가 만들어진다.
이번 연구에서 작성한 코드 역시
새로운 알고리즘을 개발하기 위한 것이 아니라,
역사학의 연구 질문을 실제 분석 가능한 형태로 구현하기 위한 과정이었다.
이러한 경험은 이후 연구노트와 검증 기록,
그리고 연구계획서 전반의 방법론 설계에도 그대로 이어지게 되었다.
결국 이번 프로젝트에서 가장 많이 수정한 것은 알고리즘이 아니라 연구 질문을 구현하는 방식이었다.