NarrativeAnalysis — 大数据电影叙事分析
基于 Hadoop 生态的大数据电影叙事分析——从剧本清洗、情感计算到 HBase 存储、API 查询的全链路实现。
- 情感词典驱动的剧本情感分析(17 部电影,80720 行清洗数据)
- 角色共现网络构建与场景分割
- 数据管道 Pipeline(预处理 → 情感分析 → HBase 写入 → API)
- RESTful API 后端服务(Flask)
- HBase 三表存储(movie_emotion / character_network / narrative_pattern)
大数据叙事分析系统
数据管道来源:本系统的数据清洗与预处理管道来源于课程 HDFS 数据处理 Pipeline(作业一),本项目的
data_pipeline.py在其基础上扩展了完整的系统集成。
项目概述
基于 Hadoop + HBase 的大规模电影剧本叙事分析系统。对多部电影剧本进行情感计算、角色网络分析和叙事模式提取,结果存入 HBase 并通过 Flask API 暴露给前端可视化展示。这是一个完整的大数据应用 demo:数据管道 -> 分布式存储 -> API 服务 -> Web 前端。
架构设计
系统包含五个组件:
数据管道(data_pipeline.py):从清洗后的剧本文件出发,加载情感词典,逐剧本分析场景情感、角色共现和叙事模式。结果以 JSON 格式输出到 api_data/ 目录,同时支持写入 HBase。
HBase 存储(hbase_schema.sh):创建三张表——movie_emotion(场景情感序列)、character_network(角色共现矩阵)、narrative_pattern(叙事模式指纹)。表结构围绕列族设计,每张表的 RowKey 为电影 ID。
Flask API(api/app.py):提供 RESTful 接口,从 HBase 读取分析数据返回 JSON 或直接读取本地 JSON 文件作为 fallback。支持按电影 ID 查询、列表查询等端点。
前端展示(frontend/index.html):可视化展示情感曲线、角色关系网络和叙事模式对比,作为数据分析结果的直观呈现。
集群启动(cluster/start-cluster.sh):一键启动 Hadoop + HBase 集群的脚本,支持 Docker 环境。
技术亮点
多级数据管道设计:Pipeline 支持三种模式(full/api/hbase),数据经过预处理 -> 情感分析 -> 结构化 JSON -> HBase 导入四个阶段。每一阶段产物可独立使用,JSON 文件可直接供前端展示,HBase 存储用于大规模查询。
情感时序分析:逐场景计算净情感值(pos-neg)/总词数,形成贯穿全剧的情感变化曲线。用于分析剧本的叙事节奏——情感曲线在剧情高潮、反转和结尾阶段呈现明显波动。
叙事模式聚类准备:每部电影提取情感均值、标准差、场景数、角色数等特征构成叙事指纹,后续可基于这些向量进行剧本聚类分析,发现叙事风格的相似性。
设计决策
HBase 列族设计遵循查询模式驱动原则:emotion 列族支持场景级情感范围扫描,cooccur 列族支持角色对查询,features 列族支持叙事特征向量提取。三张表分别服务于不同的分析视角,避免单表列族过多带来的性能问题。
Flask API 同时支持 HBase 和本地 JSON 两种数据源,使得前端开发可以不依赖 Hadoop 集群,本地 JSON 作为开发数据源,HBase 作为生产数据源。
关键代码解读
def analyze_script(filepath, movie_id, movie_name, word_dict):
# 场景分割 -> 情感计算 -> 角色共现
for line in lines:
if stripped.startswith('[SCENE]'):
# 场景切换
elif re.match(r'^[A-Z][A-Z\s\.]{1,30}$', stripped):
# 角色识别
# 情感归一化
net_emotion = round((pos_count - neg_count) / max(total_words, 1), 6)
剧本分析引擎同时完成场景分割、角色识别和情感计算三项任务。一次遍历完成所有分析,时间复杂度 O(n) 且空间占用仅与角色数量和场景数相关,与剧本长度无关。
python情感分析数据管道
预处理管道:加载情感词典 → 分割场景 → 计算情感得分 → 构建角色共现 → HBase 写入。
# ── 情感分析核心 ──
def analyze_script(filepath, movie_id, movie_name, word_dict):
"""分析单部剧本,返回结构化的情感 + 角色数据"""
with open(filepath, 'r', encoding='utf-8') as f:
text = f.read()
lines = text.split('\n')
scenes = []
current_scene = {'scene_id': 0, 'name': 'UNKNOWN',
'lines': [], 'characters': set()}
scene_counter = 0
all_characters = set()
char_scene_map = defaultdict(set)
for line in lines:
stripped = line.strip()
# 识别场景标记 [SCENE]
if stripped.startswith('[SCENE]'):
if current_scene['lines']:
scenes.append(current_scene)
scene_counter += 1
current_scene = {
'scene_id': scene_counter,
'name': stripped.replace('[SCENE] ', '').strip(),
'lines': [], 'characters': set()
}
# 识别角色名(全大写缩写)
elif stripped and re.match(r'^[A-Z][A-Z\s\.]{1,30}$', stripped):
char_name = stripped.strip()
all_characters.add(char_name)
current_scene['characters'].add(char_name)
char_scene_map[char_name].add(current_scene['scene_id'])
else:
current_scene['lines'].append(stripped)
if current_scene['lines']:
scenes.append(current_scene)
# 无场景标记时按窗口分割
if len(scenes) <= 1:
scenes = []
window_size = 50
for i in range(0, len(lines), window_size):
... # windowed fallback
# ── 情感分析 ──
emotion_results = []
for scene in scenes:
pos_count = 0
neg_count = 0
total_words = 0
for line in scene['lines']:
words = line.lower().split()
total_words += len(words)
for w in words:
w_clean = re.sub(r'[^a-z]', '', w)
if w_clean in word_dict:
p, n = word_dict[w_clean]
pos_count += p
neg_count += n
net_emotion = round((pos_count - neg_count) / max(total_words, 1), 6)
emotion_results.append({
'scene': scene['scene_id'],
'name': scene['name'],
'pos': pos_count, 'neg': neg_count,
'net': net_emotion
})
# ── 角色共现 ──
cooccur = defaultdict(int)
for scene in scenes:
chars = list(scene['characters'])
for i in range(len(chars)):
for j in range(i + 1, len(chars)):
pair = tuple(sorted([chars[i], chars[j]]))
cooccur[pair] += 1
return {
'id': movie_id, 'name': movie_name,
'total_scenes': len(scenes),
'total_characters': len(all_characters),
'scenes': emotion_results,
'cooccurrences': [
{'charA': a, 'charB': b, 'count': c}
for (a, b), c in sorted(cooccur.items(), key=lambda x: -x[1])[:30]
]
}
# ── HBase 写入 ──
def write_to_hbase(movies_data):
"""将分析结果写入 HBase(3 张表)"""
connection = happybase.Connection('localhost', 9090)
table_emotion = connection.table('movie_emotion')
table_network = connection.table('character_network')
table_pattern = connection.table('narrative_pattern')
for movie in movies_data:
mid = movie['id']
# movie_emotion: 场景情感序列
table_emotion.put(mid, {
'emotion:scenes': json.dumps(movie['scenes']),
'meta:name': movie['name'],
'meta:scenes': str(movie['total_scenes']),
'meta:characters': str(movie['total_characters']),
})
# character_network: 角色共现关系
for c in movie['cooccurrences']:
pair_key = f"{c['charA']}#{c['charB']}"
table_network.put(mid, {f"cooccur:{pair_key}": str(c['count'])})
# narrative_pattern: 叙事模式指纹
emotion_series = [s['net'] for s in movie['scenes']]
mean_emo = sum(emotion_series) / max(len(emotion_series), 1)
std_emo = (sum((e - mean_emo)**2 for e in emotion_series) / max(len(emotion_series), 1))**0.5
pattern = {
'acts': len(movie['scenes']) // 10 + 1,
'emotion_mean': round(mean_emo, 6),
'emotion_std': round(std_emo, 6),
'scene_count': movie['total_scenes'],
'char_count': movie['total_characters'],
}
table_pattern.put(mid, {
'features:fingerprint': json.dumps(pattern),
'acts:count': str(pattern['acts']),
})
connection.close()pythonFlask API 后端
RESTful API 服务,提供电影列表、情感曲线、角色共现等查询接口。
@app.route('/movies', methods=['GET'])
def list_movies():
"""获取所有电影基本信息列表"""
data = load_all_movies()
movies = [{
'id': m['id'], 'name': m['name'],
'total_scenes': m['total_scenes'],
'total_characters': m['total_characters'],
} for m in data.get('movies', [])]
return jsonify({'count': len(movies), 'movies': movies})
@app.route('/movies/<movie_id>/emotion', methods=['GET'])
def get_emotion_curve(movie_id):
"""获取电影情感曲线(含转折点检测)"""
movie = get_movie_by_id(movie_id)
if movie is None:
return jsonify({'error': f'Movie not found: {movie_id}'}), 404
scenes = movie.get('scenes', [])
emotion_curve = [{
'scene': s['scene'], 'name': s['name'],
'positive': s['pos'], 'negative': s['neg'],
'net_emotion': s['net'],
} for s in scenes]
# 检测叙事转折点(ECR 简化版)
turning_points = []
for i in range(1, len(scenes) - 1):
prev_net = scenes[i - 1]['net']
curr_net = scenes[i]['net']
next_net = scenes[i + 1]['net']
# 局部极值 = 情感趋势转折
if (curr_net > prev_net and curr_net > next_net) or \
(curr_net < prev_net and curr_net < next_net):
turning_points.append({
'scene': scenes[i]['scene'],
'net_emotion': curr_net,
'type': 'peak' if curr_net > prev_net else 'valley'
})
return jsonify({
'movie_id': movie_id,
'emotion_curve': emotion_curve,
'turning_points': turning_points,
'total_scenes': len(scenes),
})| 路径 | 说明 | 行数 |
|---|---|---|
scripts/data_pipeline.py | 数据管道(预处理→情感分析→HBase) | 315 |
api/app.py | Flask 后端 API 服务 | 180 |
scripts/preprocess.py | 剧本清洗预处理 | 85 |
api/requirements.txt | Python 依赖 | 5 |
cluster/docker-compose.yml | Hadoop 集群 Docker 配置 | 40 |
cluster/start-cluster.sh | 集群启动脚本 | 55 |
frontend/index.html | 前端可视化页面 | 320 |