Files
report-admin/scripts/era2/01_etl_transform.py
T
lofyerandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 71db82393a Initial commit
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-07-13 15:38:41 +08:00

323 lines
13 KiB
Python

#!/usr/bin/env python3
"""
二期数据 ETL 脚本
将原始平台导出格式(每题一个Sheet + 15个学科独立文件)
转换为与一期相同的扁平表格式,供后续赋分/分析引擎使用
输入:二期/[整理前] 二期_课程实施+教材使用情况表/
输出:report-admin/data/era2/ 下的3张扁平表
- era2_基础信息表.xlsx
- era2_课程实施情况表.xlsx
- era2_学科课程实施情况表.xlsx
一期目标格式:
基础信息表列: 题号, 题型, 题目ID, 题目内容, 字段ID, 字段名称, 字段值, 年级, 学期, 区, 学校名称, 办学性质, 学校类别, 学校等级, 地域类型, 特色类型, 等级B, 等级C
课程实施情况表列: 题号, 题型, 字段ID, 字段名称, 字段取值, 选项文字, 学科, 学期, 年级, 学校简称, 所在区, 学校类别, 学校性质, 所处地区, 学校类型, 学校类型编号
学科课程实施情况表列: 题号, 题型, 字段ID, 字段名称, 字段取值, 学科, 选项文字, 学校性质, 所在区, 学校类别, 学校特色, 学校简称, 所处地区, 学校类型, 学校类型编号
"""
import pandas as pd
import numpy as np
from pathlib import Path
from typing import Dict, List, Optional
import logging
import time
import glob
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)
# ===== 路径配置 =====
PROJECT_ROOT = Path(__file__).parent.parent.parent # report-admin/
DATA_ROOT = PROJECT_ROOT.parent # 20260301-邱老师-校长画像/
ERA2_RAW_DIR = DATA_ROOT / "二期" / "[整理前] 二期_课程实施+教材使用情况表"
ERA2_OUTPUT_DIR = PROJECT_ROOT / "data" / "era2"
def load_raw_file(filepath: Path) -> Dict:
"""
加载一个二期原始 Excel 文件,返回:
{
'home': DataFrame, # 首页元信息
'data_sheets': {题号: DataFrame}, # 每题的数据
'field_map': DataFrame, # 字段映射关系
'dim_map': DataFrame, # 维度映射关系
'type_stats': DataFrame, # 题型统计
}
"""
logger.info(f" 加载: {filepath.name}")
xls = pd.ExcelFile(filepath)
result = {
'home': pd.read_excel(xls, sheet_name='首页'),
'data_sheets': {},
'field_map': None,
'dim_map': None,
'type_stats': None,
}
for sn in xls.sheet_names:
if sn == '首页':
continue
elif sn == '字段映射关系':
result['field_map'] = pd.read_excel(xls, sheet_name=sn)
elif sn == '维度映射关系':
result['dim_map'] = pd.read_excel(xls, sheet_name=sn)
elif sn == '题型统计':
result['type_stats'] = pd.read_excel(xls, sheet_name=sn)
elif sn.isdigit():
result['data_sheets'][int(sn)] = pd.read_excel(xls, sheet_name=sn)
xls.close()
total_rows = sum(len(df) for df in result['data_sheets'].values())
logger.info(f" → {len(result['data_sheets'])}个题Sheet, {total_rows}行数据")
return result
def build_type_map(type_stats: pd.DataFrame) -> Dict[int, str]:
"""从题型统计构建 题号→题型 的映射"""
return dict(zip(type_stats['题号'].astype(int), type_stats['题型']))
def extract_school_short_name(full_name: str) -> str:
"""
从全称提取简称
'上海市延安中学' → '延安中学'
'华东政法大学附属中学' → '华政附中'
'华东师范大学附属天山学校' → '天山学校'
保留全称作为默认
"""
# 不做过度简化,保留全称让下游配置来处理映射
return full_name
def extract_district_name(region_str: str) -> str:
"""
从区域维度提取区名
'上海市长宁区教育学院' → '长宁区'
'上海市青浦区教师进修学院' → '青浦区'
"""
if pd.isna(region_str):
return ""
s = str(region_str)
# 提取 "XX区" 部分
for suffix in ['教育学院', '教师进修学院', '教育委员会']:
s = s.replace(suffix, '')
s = s.replace('上海市', '')
return s.strip()
# ===== 1. 基础信息表 ETL =====
def transform_basic_info(raw: Dict) -> pd.DataFrame:
"""
将二期基础信息表转为一期格式
一期列: 题号, 题型, 题目ID, 题目内容, 字段ID, 字段名称, 字段值, 年级, 学期, 区, 学校名称, ...
二期列: 问题id, 字段id, 字段名称, 字段取值, 维度id, 维度名称, 学科维度, 年级维度, 学期维度, 学校维度, 区域维度, 用户维度, 状态, 问卷提交时间
"""
logger.info("转换基础信息表...")
type_map = build_type_map(raw['type_stats'])
rows = []
for q_num, df in sorted(raw['data_sheets'].items()):
q_type = type_map.get(q_num, '未知')
for _, row in df.iterrows():
rows.append({
'题号': q_num,
'题型': q_type,
'题目ID': row.get('问题id', ''),
'题目内容': '', # 二期原始数据无此字段
'字段ID': row.get('字段id', ''),
'字段名称': row.get('字段名称', ''),
'字段值': row.get('字段取值', ''),
'年级': row.get('年级维度', '不分年级'),
'学期': row.get('学期维度', ''),
'区': extract_district_name(row.get('区域维度', '')),
'学校名称': row.get('学校维度', ''),
# 以下字段二期原始数据无,留空后续由配置补充
'办学性质': '',
'学校类别': '',
'学校等级': '',
'地域类型': '',
'特色类型': '',
'等级B': '',
'等级C': '',
})
result = pd.DataFrame(rows)
# 强制所有列为字符串,避免混合类型导致 parquet 报错
result = result.astype(str)
logger.info(f" 基础信息表: {len(result)}行")
return result
# ===== 2. 课程实施情况表 ETL =====
def transform_course_impl(raw: Dict) -> pd.DataFrame:
"""
将二期课程实施情况表转为一期格式
一期列: 题号, 题型, 字段ID, 字段名称, 字段取值, 选项文字, 学科, 学期, 年级, 学校简称, 所在区, ...
"""
logger.info("转换课程实施情况表...")
type_map = build_type_map(raw['type_stats'])
rows = []
for q_num, df in sorted(raw['data_sheets'].items()):
q_type = type_map.get(q_num, '未知')
for _, row in df.iterrows():
rows.append({
'题号': str(q_num),
'题型': q_type,
'字段ID': row.get('字段id', ''),
'字段名称': row.get('字段名称', ''),
'字段取值': row.get('字段取值', ''),
'选项文字': row.get('维度名称', ''), # 二期中维度名称对应选项文字
'学科': row.get('学科维度', '不分学科'),
'学期': row.get('学期维度', ''),
'年级': row.get('年级维度', '不分年级'),
'学校简称': row.get('学校维度', ''),
'所在区': extract_district_name(row.get('区域维度', '')),
# 以下字段二期原始数据无,留空
'学校类别': '',
'学校性质': '',
'所处地区': '',
'学校类型': '',
'学校类型编号': '',
})
result = pd.DataFrame(rows)
result = result.astype(str)
logger.info(f" 课程实施情况表: {len(result)}行")
return result
# ===== 3. 学科课程实施情况表 ETL =====
def transform_subject_impl(raw_files: Dict[str, Dict]) -> pd.DataFrame:
"""
将二期的15个学科独立文件合并为一张表(一期格式)
一期列: 题号, 题型, 字段ID, 字段名称, 字段取值, 学科, 选项文字, 学校性质, 所在区, ...
"""
logger.info("转换学科课程实施情况表(合并15个学科文件)...")
all_rows = []
for subject, raw in sorted(raw_files.items()):
type_map = build_type_map(raw['type_stats'])
subject_rows = 0
for q_num, df in sorted(raw['data_sheets'].items()):
q_type = type_map.get(q_num, '未知')
for _, row in df.iterrows():
all_rows.append({
'题号': str(q_num),
'题型': q_type,
'字段ID': row.get('字段id', ''),
'字段名称': row.get('字段名称', ''),
'字段取值': row.get('字段取值', ''),
'学科': subject,
'选项文字': row.get('维度名称', ''),
'学校性质': '',
'所在区': extract_district_name(row.get('区域维度', '')),
'学校类别': '',
'学校特色': '',
'学校简称': row.get('学校维度', ''),
'所处地区': '',
'学校类型': '',
'学校类型编号': '',
})
subject_rows += 1
logger.info(f" {subject}: {subject_rows}行")
result = pd.DataFrame(all_rows)
result = result.astype(str)
logger.info(f" 学科课程实施情况表合计: {len(result)}行")
return result
# ===== 主流程 =====
def main():
start = time.time()
print("=" * 70)
print("📊 二期数据 ETL 转换")
print(f" 输入: {ERA2_RAW_DIR}")
print(f" 输出: {ERA2_OUTPUT_DIR}")
print("=" * 70)
# 检查输入目录
if not ERA2_RAW_DIR.exists():
logger.error(f"❌ 输入目录不存在: {ERA2_RAW_DIR}")
return
# 创建输出目录
ERA2_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
# ===== 1. 基础信息表 =====
print("\n[1/3] 基础信息表")
raw_basic = load_raw_file(ERA2_RAW_DIR / "第二期_学校基础信息表.xlsx")
df_basic = transform_basic_info(raw_basic)
out_basic = ERA2_OUTPUT_DIR / "era2_基础信息表.xlsx"
out_basic_pq = ERA2_OUTPUT_DIR / "era2_基础信息表.parquet"
df_basic.to_parquet(out_basic_pq, index=False)
logger.info(f" ✅ 保存 → {out_basic_pq}")
# ===== 2. 课程实施情况表 =====
print("\n[2/3] 课程实施情况表")
raw_course = load_raw_file(ERA2_RAW_DIR / "第二期_学校课程实施情况表.xlsx")
df_course = transform_course_impl(raw_course)
out_course_pq = ERA2_OUTPUT_DIR / "era2_课程实施情况表.parquet"
df_course.to_parquet(out_course_pq, index=False)
logger.info(f" ✅ 保存 → {out_course_pq}")
# ===== 3. 学科课程实施情况表 =====
print("\n[3/3] 学科课程实施情况表(15个学科文件)")
subject_files = sorted(ERA2_RAW_DIR.glob("第二期_*学科课程实施情况表.xlsx"))
raw_subjects = {}
for f in subject_files:
subject_name = f.name.replace("第二期_", "").replace("学科课程实施情况表.xlsx", "")
raw_subjects[subject_name] = load_raw_file(f)
df_subject = transform_subject_impl(raw_subjects)
# 学科表超过Excel行数上限(1,048,576),保存为parquet + 按区分片xlsx
out_subject_parquet = ERA2_OUTPUT_DIR / "era2_学科课程实施情况表.parquet"
df_subject.to_parquet(out_subject_parquet, index=False)
logger.info(f" ✅ 保存 (parquet) → {out_subject_parquet}")
# 注:如需xlsx格式可按区分片,但parquet格式已满足分析需求
# ===== 汇总 =====
elapsed = time.time() - start
print(f"\n{'=' * 70}")
print(f"✅ ETL 转换完成! 耗时 {elapsed:.1f}s")
print(f" 基础信息表: {len(df_basic):>8,}行")
print(f" 课程实施情况表: {len(df_course):>8,}行")
print(f" 学科课程实施情况表: {len(df_subject):>8,}行")
print(f" 学校数量: {df_course['学校简称'].nunique()}")
print(f" 区域数量: {df_course['所在区'].nunique()}")
print(f" 学科数量: {df_subject['学科'].nunique()}")
print(f" 输出目录: {ERA2_OUTPUT_DIR}")
print(f"{'=' * 70}")
# 保存元信息
meta = {
'基础信息表行数': len(df_basic),
'课程实施情况表行数': len(df_course),
'学科课程实施情况表行数': len(df_subject),
'学校列表': sorted(df_course['学校简称'].unique().tolist()),
'区域列表': sorted(df_course['所在区'].unique().tolist()),
'学科列表': sorted(df_subject['学科'].unique().tolist()),
'课程实施题数': raw_course['type_stats']['题号'].max(),
'各学科题数': {s: r['type_stats']['题号'].max() for s, r in raw_subjects.items()},
}
import json
meta_path = ERA2_OUTPUT_DIR / "etl_meta.json"
with open(meta_path, 'w', encoding='utf-8') as f:
json.dump(meta, f, ensure_ascii=False, indent=2, default=str)
logger.info(f" 元信息 → {meta_path}")
if __name__ == "__main__":
main()