简介:这份2023年外泌体行业分析报告以PPTX演示文稿呈现,面向生物医药研究者、产业分析师、投资人与关注再生医学的从业者,用于系统梳理外泌体技术从基础概念到商业化落地的整体图景。压缩包共1个文件,为pptx格式,大小约4.55MB,内容按概述、行业现状、应用领域、市场分析、发展瓶颈与解决方案、研究前沿与展望等模块组织。幻灯片中既有外泌体定义、分类、结构、功能及分泌摄取机制等基础知识,也覆盖肿瘤免疫治疗、药物递送、疾病诊断、组织工程、软骨修复、神经再生、美容护肤与细胞治疗等场景,并对市场规模、超过15%的年增长预期、主要参与者、技术转化和提取纯化、检测表征等挑战做了归纳。已有93人学习下载,适合用于快速建立行业认知、整理汇报框架或辅助选题调研。
1. 从一份 2023 年外泌体行业分析报告 PPTX 说起
做行业研究的人手里大概率躺着几份这样的文件:四十到八十页,前面十几页讲技术路线,中间是市场规模和产业链图谱,后面是公司管线、融资和监管进度。真正要用的时候才发现,想回答「NTA 和 TRPS 在表征环节各占多少」「外泌体 CDMO 产能集中在哪几家」「诊断管线扎堆在哪些标志物」这类问题,只能一页页翻,翻完还无法复现。
PPTX 的本质是一个 OOXML 压缩包,正文段落、表格、图表数值、演讲者备注、贴图各自是独立部件,都能被程序按路径取出来。把「2023年外泌体行业分析报告.pptx」拆成结构化数据,价值不在转成 Markdown,而在于:指标可对账、结论可回溯到具体页码、明年新报告出来能做跨版本差异比对。
这条链路适合三类人:做投研和 BD、需要把报告变成可查询底表的人;做行业数据库、要把非结构化 PPT 灌进检索系统的人;以及做数据工程、被要求「把这份 PPT 整理成 Excel」的人。下面按 unzip 看结构、抽取与归一化、检索切块、差异校验四步走完,每步都给能直接跑的代码和会踩的坑。
2. 拆解 PPTX 的 OOXML 结构:先看清 slide、chart、embedding 三类零件
处理这类报告最常见的失败方式,是直接上python-pptx遍历slide.shapes打印文本,然后发现图表一个数字都没拿到,贴图页整页空白,产业链图谱那一页只剩几个孤零零的标签。原因是 PPTX 里「看起来是一张图的东西」至少有三种物理形态,读取方式完全不同。
2.1 一次 unzip 就能看到的目录树
把 pptx 后缀改成 zip 解开,或者直接用 Python 的zipfile遍历,会看到这样的结构:
| 部件路径 | 内容 | 读取方式 | 常见坑 |
|---|---|---|---|
ppt/slides/slideN.xml | 该页文本框、表格、图形的位置与文字 | XML / python-pptx | 页码 N 不等于放映顺序,要看presentation.xml的 sldIdLst |
ppt/notesSlides/notesSlideN.xml | 演讲者备注,常含口径说明和来源 | python-pptx 的notes_slide | 访问即创建,只读遍历要用has_notes_slide判断 |
ppt/charts/chartN.xml | 图表类型、系列名、缓存数值 | python-pptx 的shape.chart | 只给缓存值,改了内嵌表格没刷新会读到旧数 |
ppt/embeddings/*.xlsx | 图表背后的原始工作簿 | zipfile+ pandas | 一份报告可能嵌十几个,文件名无规律 |
ppt/media/imageN.png | 贴图、截图、图谱 | 导出后 OCR | 分辨率决定 OCR 质量,原图往往够用 |
docProps/app.xml | 每页标题清单TitlesOfParts | XML | 只反映占位符标题,正文页往往为空 |
一个容易被忽略的细节:很多行业报告的章节划分是写在ppt/presentation.xml的p14:sectionLst扩展里的,python-pptx 不暴露这个接口,得自己读 XML。章节名对后面切块非常有用,因为它天然对应「技术路线」「市场规模」「公司管线」这类语义边界。
import re, zipfile def read_sections(pptx_path: str) -> list[str]: """读取 PPT 自定义节(section)名称,用于后续按章节切块""" with zipfile.ZipFile(pptx_path) as z: xml = z.read("ppt/presentation.xml").decode("utf-8") # p14:sectionLst 里每个 p14:section 带 name 属性 return re.findall(r'<p14:section[^>]*name="([^"]+)"', xml)逻辑说明:p14是 Office 2010 之后的扩展命名空间,section 列表挂在presentation.xml尾部,正则匹配name属性即可拿到章节名顺序。参数上不需要额外配置;如果返回空列表,说明这份 PPT 没有用「节」组织,退化成按页切块即可,不要在这里死磕。
2.2 用 python-pptx 枚举形状时的正确姿势
slide.shapes只返回顶层形状。产业链图谱、流程图几乎一定是组合形状(group),必须递归展开,否则整块内容丢失。展开之后还要按阅读顺序排序——PPT 里形状的 XML 顺序是编辑顺序,不是人眼顺序,直接遍历会得到语序错乱的段落。
from pptx import Presentation from pptx.enum.shapes import MSO_SHAPE_TYPE EMU_PER_LINE = 228600 # 0.25 英寸,作为「同一行」的聚类阈值 def walk(shapes): """递归展开组合形状,产出所有叶子形状""" for shape in shapes: if shape.shape_type == MSO_SHAPE_TYPE.GROUP: yield from walk(shape.shapes) else: yield shape def reading_order(shapes): """按上到下、左到右排序,同一行内按 left 排""" def key(s): top = s.top if s.top is not None else 0 left = s.left if s.left is not None else 0 return (round(top / EMU_PER_LINE), left) return sorted(shapes, key=key) prs = Presentation("2023年外泌体行业分析报告.pptx") for idx, slide in enumerate(prs.slides, start=1): for shape in reading_order(list(walk(slide.shapes))): if shape.has_text_frame and shape.text_frame.text.strip(): print(idx, shape.shape_id, shape.text_frame.text[:40].replace("\n", " "))逻辑说明:EMU_PER_LINE是行聚类阈值,PPT 坐标单位是 EMU,914400 EMU 等于 1 英寸。取 0.25 英寸能在大多数版式下把同一行的左右两栏归到一行,同时不会把上下两行误并。若报告用了大字号标题(行高超过 0.5 英寸),把阈值调到 457200 更稳。shape.shape_id建议保留,后面做结论回溯时要靠它定位到具体形状。
2.3 图表不是图片:chart XML 与内嵌工作簿的入口
图表页是信息密度最高的部分,也是最容易漏的。shape.has_chart为真时可以直接拿系列名和数值,has_table为真时拿表格。但要注意两个陷阱:一是组合形状里的图表需要递归后判断;二是图表缓存值可能和你看到的渲染结果不一致,严谨场景要回到内嵌 xlsx 取数。
import io, zipfile import pandas as pd def dump_charts(prs): for idx, slide in enumerate(prs.slides, start=1): for shape in walk(slide.shapes): if not getattr(shape, "has_chart", False): continue ch = shape.chart cats = list(ch.plots[0].categories) # 横轴分类 for s in ch.series: print(idx, ch.chart_type, s.name, cats[:3], list(s.values)[:3]) def read_embedded_workbooks(pptx_path: str) -> dict: """把 ppt/embeddings 下所有 xlsx 读成 DataFrame 字典,键为部件名""" out = {} with zipfile.ZipFile(pptx_path) as z: names = [n for n in z.namelist() if n.startswith("ppt/embeddings/") and n.endswith(".xlsx")] for n in names: out[n] = pd.read_excel(io.BytesIO(z.read(n)), sheet_name=None, header=None) return out逻辑说明:ch.plots[0].categories是横轴标签,多系列图表的分类在第一个 plot 上;s.values是纵轴数值,注意它可能是None(系列只有名称没有数据),要做空值保护。read_embedded_workbooks用header=None读,因为报告里的图表工作簿首行经常是标题而不是表头,强行让 pandas 猜表头会把真实第一行数据吃掉。拿到的 DataFrame 字典再按内容判断哪张表对应哪个图表,用系列名做匹配最可靠。
提示:如果只想快速确认某页图表的数据源,把 pptx 解压后直接搜
ppt/embeddings/,按修改时间排序,最新写入的通常就是刚编辑过的那张图。
3. 把外泌体行业指标抽成结构化表:文本、表格、图表三路合流
拿到零件之后要解决的是「口径」问题。同一份 2023 年外泌体行业分析报告里,市场规模可能同时出现「亿元」「亿美元」「百万美元」,分离工艺可能写作「超速离心」「UC」「差速离心」三种说法,检测方法写作「NTA」「纳米颗粒跟踪分析」。不归一化就做不了聚合,也做不了跨版本对账。
3.1 正文与表格抽取:按阅读顺序重建段落
正文抽取的关键是不要按 run 切。一个中文句子的不同字词可能被拆成多个 run(改过字体、加粗就会拆),按 run 输出会得到碎片。正确粒度是段落:text_frame.paragraphs里每个 paragraph 的text是完整一行。
表格抽取要处理合并单元格。python-pptx 会把被合并区域里的每个格子都返回同一段文字,直接落表会出现大量重复值。相邻去重是最省事且效果稳定的做法。
def table_to_rows(tbl) -> list[list[str]]: rows = [] for row in tbl.rows: cells = [c.text.strip().replace("\n", " ").replace("\u00a0", " ") for c in row.cells] # 合并单元格会让同一文本连续重复,按相邻去重压缩 merged = [c for i, c in enumerate(cells) if i == 0 or c != cells[i - 1]] rows.append(merged) return rows def slide_text_blocks(slide) -> list[dict]: blocks = [] for shape in reading_order(list(walk(slide.shapes))): if shape.has_table: for r in table_to_rows(shape.table): blocks.append({"kind": "table_row", "shape_id": shape.shape_id, "text": " | ".join(x for x in r if x)}) elif shape.has_text_frame and shape.text_frame.text.strip(): for p in shape.text_frame.paragraphs: t = p.text.strip() if t: blocks.append({"kind": "text", "shape_id": shape.shape_id, "text": t}) return blocks逻辑说明:相邻去重只能解决横向合并,纵向合并(整列合并的「技术路线」大类)仍会产生跨行重复,这类要在 DataFrame 层用前向填充处理,而不是在抽取层丢数据。kind字段区分text和table_row是有意保留的——表格行在检索时应该整体召回,拆成单格会丢掉「指标名 + 数值 + 单位」的上下文。
3.2 图表数值还原:读 ppt/embeddings 下的 xlsx
图表页里最典型的是「2019—2023 年全球外泌体市场规模及增速」这类双轴图:柱状是规模,折线是增速,系列名写在图例里。用shape.chart拿到的系列名往往是「系列1」,因为制图时没改默认名。这时候必须回到内嵌工作簿,靠行列结构还原语义。
常见做法是:先读所有内嵌 xlsx,找出区域面积最大、且第一列是年份或类别的那张表,把首行当系列名、首列当分类,转成长表结构。
import numpy as np def xlsx_to_long(df: pd.DataFrame, sheet_hint: str = "市场规模"): """把宽表(首行系列名、首列分类)转成长表,便于与正文指标合并""" raw = df.dropna(how="all").dropna(axis=1, how="all") if raw.shape[0] < 3 or raw.shape[1] < 2: return None header = [str(x) for x in raw.iloc[0].tolist()] body = raw.iloc[1:].copy() body.columns = header first_col = body.columns[0] long = body.melt(id_vars=[first_col], var_name="series", value_name="value") long = long.rename(columns={first_col: "category"}) long["value"] = pd.to_numeric(long["value"], errors="coerce") long["sheet_hint"] = sheet_hint return long.dropna(subset=["value"])逻辑说明:dropna(how="all")与dropna(axis=1, how="all")用来清掉工作簿里的大片空行列,这是图表工作簿的常态。header = raw.iloc[0]是冒险但有效的假设——制图数据的首行几乎总是系列名。若返回的series全是「系列1」「系列2」,说明首行不是表头,此时退回用shape.chart的系列名加categories手工对齐,不要硬套。
3.3 领域词典与单位归一化:把「超速离心/UC」合成一个实体
归一化要建两张表:实体别名表和单位换算表。前者靠外泌体领域的常识先写一版,再从上一步抽出的全量文本里用频次补充;后者处理「亿元 / 亿美元 / 百万美元」这类混用。
| 原始写法 | 归一化标签 | 类型 | 说明 |
|---|---|---|---|
| 超速离心、UC、差速离心 | 分离_超速离心 | 工艺 | 差速离心通常指同一路径的前置步骤,合并统计 |
| SEC、尺寸排阻、凝胶过滤 | 分离_SEC | 工艺 | 与 UC 常串联使用,标签不互斥 |
| TFF、切向流过滤 | 分离_TFF | 工艺 | 多用于中试放大场景 |
| NTA、纳米颗粒跟踪分析 | 表征_NTA | 表征 | 常与 TRPS、DLS 并列出现 |
| CD63、CD81、CD9、TSG101 | 标志物_四跨膜蛋白 | 标志物 | MISEV 指南里的经典组合,检索时按组召回 |
| 亿元、亿美元、百万美元 | 金额_* | 单位 | 统一折算到人民币亿元,注明汇率假设 |
import re ALIAS = { "分离_超速离心": ["超速离心", "UC", "ultracentrifugation", "差速离心"], "分离_SEC": ["SEC", "尺寸排阻", "凝胶过滤", "size exclusion"], "分离_TFF": ["TFF", "切向流过滤"], "表征_NTA": ["NTA", "纳米颗粒跟踪分析"], } def normalize_entity(text: str) -> list[str]: hits = [] for label, words in ALIAS.items(): if any(w.lower() in text.lower() for w in words): hits.append(label) return hits UNIT = {"亿元": 1.0, "亿": 1.0, "万美元": 0.0007, "百万美元": 0.007, "亿美元": 0.07, "百万": 1e6} def parse_amount(text: str, fx: float = 7.0) -> float | None: m = re.search(r"([\d,]+(?:\.\d+)?)\s*(亿美元|百万美元|万美元|亿元|亿)", text) if not m: return None num = float(m.group(1).replace(",", "")) unit = m.group(2) if unit.endswith("美元"): # 先按汇率折成人民币,再统一到「亿元」 usd = num * {"亿美元": 1e8, "百万美元": 1e6, "万美元": 1e4}[unit] return usd * fx / 1e8 return num * {"亿元": 1.0, "亿": 1.0}[unit]逻辑说明:normalize_entity用子串匹配而非分词,因为「超速离心」这类词在 jieba 默认词典里可能被切碎,反而不如直接匹配稳定;代价是「UC」这种短英文缩写容易误命中(比如单词内含 UC),实际使用时对纯 ASCII 缩写加词边界断言\bUC\b。parse_amount的fx是汇率参数,必须显式传参并在结果表里留一列记录假设值——行业报告做同比时,汇率口径不一致是结论翻车的头号原因。
4. 让报告可检索:按 slide 切块、加元数据、做混合召回
抽完数据只是第一步。真正被高频使用的形态是「问一句话,返回报告里的具体页码和原文」。把几十页 PPT 切成检索单元时,最忌讳按固定字数硬切——指标名和数值被切到两个块里,谁都答不准。
4.1 切块策略:标题-正文-表格行三层粒度
合理粒度是三层的:一张 slide 作为一个父块,slide 内的标题、正文段落、表格行各自作为子块。父块用于给子块补上下文(页码、章节、页标题),子块用于精确命中。
| 字段 | 示例 | 用途 |
|---|---|---|
slide_no | 27 | 答案回溯到具体页码 |
section | 公司管线 | 从p14:sectionLst取,用于过滤 |
block_kind | text / table_row / chart_series | 不同块给不同权重 |
entity_tags | 分离_SEC, 表征_NTA | 结构化过滤条件 |
shape_id | 105 | 定位到页面上的具体形状 |
页标题怎么取?如果是标题占位符,用slide.shapes.title.text;如果不是(行业报告大量使用手绘标题框),用阅读顺序里 top 最小、字号最大的文本框内容兜底。取不到就留空,不要用第一段正文冒充标题。
4.2 BM25 加向量混合召回的最小实现
行业问答里两类查询都有:精确术语查询(「TSG101」)和语义查询(「哪些工艺适合放大生产」)。只用向量,术语会漂;只用 BM25,同义改述召回不到。混合召回是性价比最高的选择。
import jieba, numpy as np from rank_bm25 import BM25Okapi STOP = set("的 了 和 与 及 在 是 为 对 中 上 下 等 一 个 以及".split()) ALIAS_WORDS = ["超速离心", "尺寸排阻", "切向流过滤", "纳米颗粒跟踪分析", "外泌体", "标志物", "冷冻电镜", "电穿孔"] def tokenize(text: str) -> list[str]: for w in ALIAS_WORDS: jieba.add_word(w, freq=20000) # 保证领域词不被切碎 return [w for w in jieba.lcut(text.lower()) if w.strip() and w not in STOP and len(w) > 1] def hybrid_search(query, chunks, embed_fn, top_k=8, alpha=0.5): """alpha 控制向量权重:0 纯 BM25,1 纯向量""" corpus = [tokenize(c["text"]) for c in chunks] bm25 = BM25Okapi(corpus) bm_scores = np.array(bm25.get_scores(tokenize(query))) doc_vecs = np.array([embed_fn(c["text"]) for c in chunks]) q_vec = np.array(embed_fn(query)) cos = doc_vecs @ q_vec / (np.linalg.norm(doc_vecs, axis=1) * np.linalg.norm(q_vec) + 1e-9) norm = lambda x: (x - x.min()) / (x.max() - x.min() + 1e-9) final = alpha * norm(cos) + (1 - alpha) * norm(bm_scores) order = np.argsort(-final)[:top_k] return [(chunks[i], float(final[i])) for i in order]逻辑说明:jieba.add_word必须在分词前调用且只调一次,反复调用会拖慢;频率给 20000 是为了压过默认词典里的切分倾向。alpha=0.5是行业报告的稳妥起点——术语密度高、表述相对固定的文档,把alpha调到 0.3 会更准;如果用户提问偏口语化,调到 0.7。两路分数必须先做 min-max 归一再加权,BM25 分值和余弦相似度量纲完全不同,直接相加等于让 BM25 单方面决定排序。
4.3 元数据过滤:按章节、页码、指标类型收窄
混合召回之后经常需要收窄范围,比如「只看公司管线那一节的表格行」。这类需求用元数据过滤比调权重有效得多,也更好解释。
def filter_chunks(chunks, section=None, kinds=None, page_range=None): out = chunks if section: out = [c for c in out if section in (c.get("section") or "")] if kinds: out = [c for c in out if c.get("block_kind") in kinds] if page_range: lo, hi = page_range out = [c for c in out if lo <= c.get("slide_no", -1) <= hi] return out逻辑说明:三个条件都是可选收窄,不做任何默认值兜底,避免调用方以为过滤生效实际没有。page_range在真实场景里很常用——用户会说「后面附录那部分」,此时先用章节名定位页码范围,再传进来。返回结果的块要带上slide_no和shape_id,前端做高亮跳页时不必再查一次原始文件。
注意:表格行子块入库时,建议把同一张表的前两行(通常是表头和大类)拼到每个子块的文本前面,成本很低,但能把「数值行脱离表头无法理解」这类错误大幅压下去。
5. 进阶校验:跨版本差异比对与结论回溯
把 2022 和 2023 两份外泌体行业分析报告一起处理,能做一件单份报告做不到的事:找出结论被改动的具体位置。这在尽调和投资复核里比单份问答更值钱。
5.1 数值级 diff:按指标主键对齐两年数据
对齐的前提是两张宽表都已经归一化到「指标名 + 年份 + 数值 + 单位」的长表结构。主键取指标名的归一化标签,而不是原始字符串,否则「超速离心占比」和「UC 占比」会被当成两个指标。
import pandas as pd def diff_metrics(old: pd.DataFrame, new: pd.DataFrame, key="指标", tol=0.01): cmp = old.merge(new, on=key, how="outer", suffixes=("_2022", "_2023"), indicator=True) added = cmp[cmp["_merge"] == "right_only"][[key]] dropped = cmp[cmp["_merge"] == "left_only"][[key]] both = cmp[cmp["_merge"] == "both"].copy() both["变化率"] = (both["数值_2023"] - both["数值_2022"]) / \ both["数值_2022"].abs().replace(0, pd.NA) changed = both[both["变化率"].abs() > tol] return added, dropped, changed.sort_values("变化率", key=lambda s: s.abs(), ascending=False)逻辑说明:indicator=True一次性区分新增、删除、共有三类指标,比做两次集合运算清晰。tol=0.01是相对阈值,用来过滤四舍五入和汇率微小波动带来的噪声。replace(0, pd.NA)防止基期为零时除零报错——报告里「新增品类」的基期经常就是 0,这类行应该单独看,而不是塞进变化率排序。
5.2 幻灯片级 diff 与人工复核工作流
数值对完还要看表述。用difflib对同一页的文本块序列做差异比对,能把「措辞调整」和「结论反转」区分开。
| 变更类型 | 判定规则 | 处置 |
|---|---|---|
| 数值调整 | 同一指标变化率超阈值 | 自动进复核清单,标注页码 |
| 新增指标 | 新版有、旧版无 | 判断是统计口径扩展还是新赛道出现 |
| 文本微调 | 字符级相似度高于 0.9 | 可忽略,除非含「首次」「唯一」等强断言 |
| 结论反转 | 相似度低且含否定词或趋势词 | 必须人工确认,优先级最高 |
| 页面新增 | 新版页数多出且无对应页 | 整页进复核清单 |
import difflib def slide_text_diff(old_blocks, new_blocks, threshold=0.9): old_txt = "\n".join(b["text"] for b in old_blocks) new_txt = "\n".join(b["text"] for b in new_blocks) ratio = difflib.SequenceMatcher(None, old_txt, new_txt).ratio() if ratio < threshold: detail = [l for l in difflib.unified_diff( old_txt.splitlines(), new_txt.splitlines(), lineterm="", n=0) if l.startswith(("+", "-")) and not l.startswith(("+++", "---"))] return {"ratio": ratio, "diff": detail} return None逻辑说明:页级对齐先用章节名加页标题做粗匹配,匹配不上的页再按页面顺序兜底,能避免因中间插页导致后面全部错位。SequenceMatcher的ratio是基于字符的,对中文长度差异不敏感,所以阈值给 0.9;n=0让 diff 只输出变更行本身,方便直接渲染成复核清单。所有差异结果落成一张带slide_no的 CSV,挂到复核看板上,人工只处理清单里的行,不再整份重读。
本文还有配套的精品资源,点击获取