ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Python解析MCM题目docx:题面结构化、关键词与约束抽取

2026/9/18 17:19:02 拓冰建站 浏览量
Python解析MCM题目docx:题面结构化、关键词与约束抽取 简介2022年MCM美国大学生数学建模竞赛的两道赛题原文汇总适合备战美赛的本科生、建模社团成员及希望研读英文原题的指导教师使用。内容覆盖Problem A单板滑雪场半管/U型池形状优化与Problem B中继站协调频率设计前者要求以最大化垂直腾空为目标并权衡空中身体扭曲、场地实用性等要求后者需在半径40英里的圆形区域内用最少中继站容纳1000名同时在线用户涉及145—148MHz频谱、收发频率600kHz间隔、54个PL连续编码音调以及CTCSS抗干扰机制还延伸到10000用户与山区视距传播受阻情形。资源包仅含1个docx文件约8KB为纯题目文本便于快速查阅与打印。目前已有642人学习可作为赛题拆解、英文术语对照与建模思路梳理的起点。1. 一份 2022 年 MCM 题目 docx凭什么要当成数据资产来拆带过队的都熟悉这个场景赛后复盘、给新队员讲题、写选题攻略手边都躺着一份 2022 年 MCM 美国大学生数学建模大赛题目的 docx。可一旦想按条件找东西就发现不行了——“哪道题在附件里给了数据”“哪道题正文里出现了 forecast 和 uncertainty”“哪道题的任务清单是四问”CtrlF 搜出来的命中位置散在分栏排版、表格和公式之间没法按题号归并也没法横向比较。把这份文件当数据资产拆开目标很具体六道题各自一条记录带题号、标题、正文、任务条目、约束里的数字与单位、关键词、是否附数据文件。有了这张表选题、检索、复盘都是查表不是翻文档。适合三类人带队教练需要横向比题选手想在赛前快速摸清某类题型的题面套路做文档处理的工程师需要一个真实的多栏、含公式、含嵌入表格的 docx 样本练手。2. 用 python-docx 拆开 2022 年 MCM 题目 docx 的题面主干2.1 先搞清楚 docx 里有几条信息通道docx 本质是个 zip正文在word/document.xml样式在word/styles.xml自动编号规则在word/numbering.xml插图在word/media/而嵌入的 xlsx 或 csv 会落在word/embeddings/。python-docx 只覆盖其中一部分段落、run、表格、节它能读但公式OMML不会被解析成文本图片只给出关系不给出像素嵌入的表格文件更是完全不出现在Document对象里。这意味着纯 python-docx 的方案一定丢东西必须配合zipfile自己开第二条通道。MCM 题目里公式不少——增长率、约束不等式、目标函数都会被排成 OMML如果只读paragraph.text会得到一堆“看起来句子被截断了”的结果后面做关键词统计时误差很难解释。提示动手前先用unzip -l把目录列一遍哪些内容需要额外通道一眼就能定下来别急着写解析循环。2.2 最小可运行先把段落和样式名打出来不要一上来就设计 schema先看清文件长什么样。下面这段把前若干段的序号、样式名和文本前缀打出来样式名决定了后面怎么切分章节from docx import Document PATH 2022_MCM_ICM_Problems.docx doc Document(PATH) for i, p in enumerate(doc.paragraphs[:30]): print(i, repr(p.style.name), |, p.text[:60]) print(tables:, len(doc.tables), sections:, len(doc.sections))p.style.name返回的是样式名英文版 Word 是Heading 1中文版 Word 存的是标题 1。len(doc.sections)在分栏题面里通常大于 1说明文档做过分节这会影响到后面按“节”切块的逻辑。先跑一遍把样式名的实际取值记下来比背文档有用。2.3 段落文本的三个坑软回车、制表符、域paragraph.text只拼接w:t节点遇到w:brShiftEnter 软回车和w:tab直接跳过于是两行任务说明会被粘成一句。正确的做法是遍历段落 XML 的节点流按节点类型补字符from docx.oxml.ns import qn def para_text(p): buf [] for node in p._p.iter(): if node.tag qn(w:t): buf.append(node.text or ) elif node.tag in (qn(w:br), qn(w:cr)): buf.append(\n) # 软回车还原成换行否则任务条目会粘连 elif node.tag qn(w:tab): buf.append(\t) return .join(buf)p._p.iter()是按文档顺序深度遍历所以文本顺序和视觉顺序一致这一点比按p.runs遍历更可靠——runs会把公式、图片所在的 run 跳过导致文本断层。qn()的作用是把w:t这种前缀写法展开成完整的命名空间 URI避免手写长串。2.4 标题样式被本地化时用 outlineLvl 兜底同一个 docx 在中文 Word 和英文 Word 里打开的样式名可能不同硬编码字符串映射迟早出事。稳妥做法是优先读段落属性里的w:outlineLvl它记录的是大纲层级与显示语言无关读不到再退回样式名表样式名outlineLvl语义处理方式Heading 1 / 标题 10题号标题行切题边界候选Heading 2 / 标题 21背景、任务、数据说明语义块标签Heading 3 / 标题 32子任务条目任务计数Normal / 正文无正文段落合并进 bodySTYLE_HINT {Heading 1: 1, 标题 1: 1, Heading 2: 2, 标题 2: 2, Heading 3: 3, 标题 3: 3} def heading_level(p): pPr p._p.find(qn(w:pPr)) if pPr is not None: ol pPr.find(qn(w:outlineLvl)) if ol is not None: return int(ol.get(qn(w:val))) 1 # OOXML 从 0 起算转成 1 起算 return STYLE_HINT.get((p.style.name or ).strip(), 0)返回 0 表示普通段落。有了它就能把扁平段落流还原成「题号—语义块—句子」三层结构而不是一个长长的字符串。2.5 表格与嵌入选做题数据两条支线一起抽题面里的表格有两种一种是版式表格用来造多栏效果没有表头另一种是真正的数据表比如变量含义表、单位换算表。用首行是否含表头关键词来过滤可以挡住大部分版式表格import zipfile def table_rows(table): out [] for r in table.rows: cells, seen [], set() for c in r.cells: if c._tc in seen: # 合并单元格在 row.cells 中会重复出现必须去重 continue seen.add(c._tc) cells.append(.join(p.text for p in c.paragraphs).strip()) out.append(cells) return out HEAD_KEYS (problem, variable, unit, symbol, year, 题) tables [] for t in doc.tables: rows table_rows(t) if rows and any(k in .join(rows[0]).lower() for k in HEAD_KEYS): tables.append({header: rows[0], rows: rows[1:]}) with zipfile.ZipFile(PATH) as z: embedded [n for n in z.namelist() if n.startswith(word/embeddings/)]c._tc是底层 XML 元素用它做集合判断才能真正去重只看文本内容会在两格刚好文字相同时误删。embedded列出嵌入对象通常包含一两个 xlsx这正是判断“这道题要不要做数据清洗”的硬证据。到这一步一道题的主干——段落、层级、表格、附件——就都拿到手了。3. 从 2022 年 MCM 题面文本到关键词、约束与题型标签3.1 切出六道题的边界正则加人工锚点六道题在文档里是靠标题行分隔的A、B、C 属 MCMD、E、F 属 ICM一条正则就能抓住但要加长度限制防止正文里的“Problem A”被误判import re BOUNDARY re.compile(r^\s*(?:Problem|问题)\s*([A-F])\s*[:]?\s*(.*)$, re.I) def split_problems(paras): out, cur [], None for p in paras: m BOUNDARY.match(p[text]) if m and len(p[text]) 200: # 标题行不会太长正文引用会超长 cur {id: m.group(1).upper(), title: m.group(2).strip(), body: []} out.append(cur) elif cur is not None: cur[body].append(p[text]) return out正则组([A-F])是题号(.*)是标题行剩余部分。len(...) 200这个阈值不是拍脑袋标题行通常不超过两行而正文里引用其他题号时往往还接着一整段说明长度差异是天然的判别特征。3.2 用 TF-IDF 拉关键词六个文档的语料怎么设参只有六篇文档常规参数会失效。max_df设 0.9 时像 model、data 这种每篇都出现的词会被过滤掉留下的是题面独有术语——这正是选题时想看的。双词组合必须开因为“carbon sequestration”“supply chain”这类术语拆成单个词就失去意义from sklearn.feature_extraction.text import TfidfVectorizer docs [ .join(p[body]) for p in problems] vec TfidfVectorizer( lowercaseTrue, stop_wordsenglish, ngram_range(1, 2), # 术语多为双词必须开 bigram min_df1, max_df0.9, # 仅 6 篇文档max_df 太松会让通用词混进来 sublinear_tfTrue, # 长题面的词频需压缩否则长文档霸榜 token_patternr(?u)\b[A-Za-z][A-Za-z\-]{2,}\b, # 滤掉纯数字与单字母 ) X vec.fit_transform(docs) names vec.get_feature_names_out() for i, p in enumerate(problems): row X[i].toarray()[0] top row.argsort()[::-1][:10] p[keywords] [names[j] for j in top if row[j] 0]sublinear_tf用 1log(tf) 代替原始词频题面长的题目不会因为词多就占满关键词列表。token_pattern默认会把单字符和数字也算成 token改成至少三个字母并允许连字符后state-of-the-art这类写法能整体保留。3.3 自动生成约束清单数字加单位加情态词题目里的硬约束大多长成“数量 单位 比较词”的形式两段正则可以覆盖大部分NUM_UNIT re.compile( r(?Pnum\d(?:\.\d)?)\s* r(?Punit%|percent|km|kilometer|m|meter|kg|ton|tonne| ryear|years|day|days|hour|hours|USD|dollar|MW|GW|kWh)\b, re.I) MODAL re.compile(r\b(no more than|at least|within|less than| rgreater than|must not exceed|up to)\b, re.I) constraints [] for p in problems: for sent in re.split(r(?[.;])\s, .join(p[body])): if NUM_UNIT.search(sent) and MODAL.search(sent): constraints.append({id: p[id], text: sent.strip()[:200]})两个条件同时命中才收录是为了挡掉描述性数字年份、题号、页码。抽出来的句子直接就是建模时的边界条件写进论文的 Assumptions 一节几乎不用改。3.4 题型标签A/B/C 与 D/E/F 的判别特征教练圈有一种粗分法按题号位和关键词组合判断题型命中两条以上再打标签比单看一个词靠谱题号位判别关键词示例常走的技术路线Acontinuous, rate, force, motion, decay微分方程、量纲分析、参数辨识Bdiscrete, network, schedule, assign, routing整数规划、图论、离散事件仿真Cdataset, forecast, time series, predict, correlation回归、时间序列、树模型Doperations, flow, capacity, queue, service多目标优化、排队论、网络流Esustainable, emission, climate, resource, ecosystem系统动力学、生命周期评价、多目标Fpolicy, framework, decision, global, stakeholder综合评价、情景分析、博弈这张表当作先验不当作结论。真实题面经常跨类比如一道带数据的题既命中 C 的预测词又命中 E 的环境词这时候标签应该输出两个并标注主次让选题的人自己权衡。4. 把解析结果接进建模流水线字段、映射与自动选题4.1 统一 schema一道题最少要有哪些字段字段定少了后面要回头补定多了没人维护。按实际用得到的场景九个字段够用字段类型来源用途problem_idstr正则捕获主键唯一titlestr边界行列表展示body_textstr段落合并全文检索headingslistoutlineLvl定位任务清单task_countint三级标题计数工作量估算keywords_top10listTF-IDF选题筛选constraintslist数字加情态词建模边界attachmentslistzip 通道数据可用性source_hashstrsha256增量更新task_count直接决定一道题要写几个模型、画几张图是赛前估算时间最实用的一个指标attachments决定要不要提前准备 pandas 清洗脚本。这两个字段比标题本身更能影响选题决策。4.2 落成 JSON 并入库注意中文与换行import json, hashlib def file_hash(path, chunk1 20): h hashlib.sha256() with open(path, rb) as f: for b in iter(lambda: f.read(chunk), b): h.update(b) return h.hexdigest()[:16] for p in problems: p[body_text] \n.join(p.pop(body)) p[source_hash] file_hash(PATH) with open(mcm2022_problems.json, w, encodingutf-8) as f: json.dump(problems, f, ensure_asciiFalse, indent2)ensure_asciiFalse保证中文注释和术语不被转义成\uXXXX直接肉眼可读indent2让 git diff 有意义——后续任何一次解析规则调整改动都能在 diff 里看清楚。file_hash只取前 16 位十六进制够用且短。4.3 按关键词推建模路线一张可维护的映射表把第 3 章的题型表变成可执行代码关键词命中即追加候选模型用集合去重后排序输出MODEL_MAP { rate: [常微分方程, 参数辨识], forecast: [ARIMA, 梯度提升树], schedule: [整数规划, 约束满足], emission: [多目标优化, 情景分析], network: [图论最短路, 网络流], decision: [层次分析法, 熵权法], queue: [排队论, 离散事件仿真], } def recommend(keywords): hits {} for kw in keywords: for key, models in MODEL_MAP.items(): if key in kw: for m in models: hits[m] hits.get(m, 0) 1 return sorted(hits, keyhits.get, reverseTrue)[:5]注意匹配的是key in kw而不是相等——TF-IDF 出来的是 bigramtime series forecast这种短语只有做子串匹配才能命中forecast。得分相同的情况下按字母序排输出结果才是可复现的。4.4 生成选题卡片直接给队员看for p in problems: lines [f### {p[problem_id]} — {p[title]}, f- 关键词{, .join(p[keywords_top10][:5])}, f- 任务条目{p[task_count]} 条, f- 附带数据{有 if p[attachments] else 无}, f- 推荐路线{, .join(recommend(p[keywords_top10]))}, f- 硬约束示例{p[constraints][0][text][:80] if p[constraints] else 未抽取到}] print(\n.join(lines), \n)输出就是一份可以贴进群里的选题卡片。constraints[0]前做空判断很重要——总有题目正文里没有“数字加情态词”的句式这时候直接索引会抛异常把整批生成卡死。注意推荐路线只是候选池最终选题要结合队员熟悉的方法和能拿到的数据不要让脚本替人做决定。5. 校准解析质量抽样核对、差异比对与增量更新5.1 抽三段核对盯三条线解析脚本最容易出的问题是“静默丢内容”跑完不报错但某道题的正文少了一半。用三条线交叉验证任何一条偏差过大都要停下来看原文校验项做法通过阈值字符量len(body_text)与原始段落拼接长度比偏差小于 2%段落数合并后的换行数与原doc.paragraphs数量比允许少 5% 以内关键词合理人工看 top5 是否都是通用词通用词不超过 2 个第二条的 5% 余量是留给空段落和分页符的它们本来就该被丢掉。第三条是质量判断里最省事的一条——如果一道题的关键词前五全是 model、data、system说明停用词表或者max_df没设对回去调参比逐段核对快得多。def check(p, raw_len): got len(p[body_text]) ratio got / max(raw_len, 1) flag OK if 0.98 ratio 1.02 else REVIEW return f{p[problem_id]} {got}/{raw_len} {ratio:.3f} {flag}5.2 用 hash 做增量更新别整份重跑题目文件常会被重新排版或补发勘误整份重跑既慢又会把人工标注的字段覆盖掉。把source_hash存进 JSON更新时先比 hash再决定是否重解析old {p[problem_id]: p for p in json.load(open(mcm2022_problems.json, encodingutf-8))} new_hash file_hash(PATH) if all(p.get(source_hash) new_hash for p in old.values()): print(文件未变跳过重解析) else: for p in old.values(): p[source_hash] new_hash # 只刷新标记人工字段原样保留hash 一致就整批跳过不一致时也只刷新解析派生的字段recommended_models、人工备注这类字段保留。真要把颗粒度做细可以把source_hash从文件级换成段落级——对每个段落的文本单独算 hash 存成列表两次比对就能定位到具体哪一段被改动。本文还有配套的精品资源点击获取