简介:《数据产业图谱高清版》是一份面向数据产业研究、企业选型与行业分析场景的PDF图谱,适合需要快速了解数据资源、数据应用、数据服务、数据技术及数据基础设施等板块分类的从业者与学生。压缩包内为1个PDF文件,大小约24.09MB,高清版便于查看企业名称与领域划分。已有344人学习,说明其对产业全景梳理具有一定参考价值。内容按公共数据资源、互联网数据资源、行业数据资源以及城市治理、工业、交通、金融、医疗健康、能源、农业等应用方向展开,并覆盖数据合规、交易、经纪、认证、咨询、资产评估等服务环节,同时列出大数据平台、智能算法、智能芯片、数据存储、可视化、区块链、隐私计算、云平台与数据安全等技术设施类别。读者可据此建立产业链地图、查找细分赛道代表机构、辅助方案调研与汇报材料整理。
1. 数据产业图谱高清版.pdf:先想清楚你要从里面拿走哪张表
拿到《数据产业图谱高清版.pdf》的人,常见动作是从头翻到尾,然后把它归到“参考资料”。真要用起来,先问一句:你要从里面拿走哪张表?这份图谱按数据资源企业、数据应用企业、数据服务企业、数据技术企业、数据基础设施企业以及交易中心等类别罗列了大量主体,适合做区域产业摸底、招商线索筛选、竞品盘点和方案底稿;不适合替代实时工商数据。把它当“可读的PDF”只能看个热闹,把它拆成“主体-类目-来源页”的表,才能做统计和复核。这一步不难,但顺序错了,后面全是返工。
2. 把PDF拆成可计算文本:文本层体检、pdfplumber与OCR的取舍
2.1 先做文本层体检:为什么不能上来就OCR
PDF分两类:文本型PDF和扫描型PDF。文本型PDF里的字是字符对象,直接抽就能拿到;扫描型PDF本质是图片,必须走OCR。很多人一上来就OCR,结果把本来清晰的文本层又识别了一遍,错字反而更多。我的习惯是先跑一遍页级体检,看每页能抽出多少字符、替换符多不多、空白页占比高不高,再决定哪几页补OCR。
体检指标不用搞得太复杂,关键看三个数:每页去空白后的字符数、替换符和方框符数量、连续空页数量。字符数突然掉到个位数,或者替换符明显增多,基本就是扫描页或字体嵌入有问题。名单页尤其要单独看,因为名单页经常是分栏排版,文本层顺序未必等于阅读顺序。
| 体检指标 | 经验阈值 | 动作 |
|---|---|---|
| 每页字符数 | 少于50且页面有内容 | 标记为候选OCR页 |
| 替换符/方框符 | 单页超过5个 | 检查字体嵌入,必要时OCR |
| 连续空页 | 连续2页以上 | 确认是否插页或图片页 |
| 可选中文字 | 整页无法选中 | 直接走OCR |
提示:文本层体检不要只看第一页,目录页、名单页、附录页的版面经常不一样。
import pdfplumber from pathlib import Path pdf_path = Path("data_industry_map.pdf") out_dir = Path("work/page_text") out_dir.mkdir(parents=True, exist_ok=True) with pdfplumber.open(pdf_path) as pdf: for i, page in enumerate(pdf.pages, start=1): # x_tolerance 调小一点,避免把相邻词粘成一个词 # y_tolerance 控制行合并,名单页不要放太大 text = page.extract_text(x_tolerance=1.5, y_tolerance=3) or "" chars = len(text.replace("\n", "")) bad = text.count("\ufffd") + text.count("□") with open(out_dir / f"page_{i:03d}.txt", "w", encoding="utf-8") as f: f.write(text) print(f"page={i:03d} chars={chars} bad={bad}")这段代码做的是“页级体检加中间态落盘”,不是最终抽取。x_tolerance调小会让字符切分更保守,适合字距紧的名单页;调大容易把“数据 资源”粘成“数据资源”,但也可能把两列文字连在一起。y_tolerance控制同一行文字的合并范围,默认值在普通正文上问题不大,名单页建议先按3跑一遍,再根据串行情况微调。bad统计的是替换符和方框符,数量异常升高通常意味着字体没嵌入或页面是图片。
2.2 用pdfplumber抽页级文本与词坐标
只抽纯文本不够,名单页一旦分栏,纯文本会按坐标顺序输出,读起来像被洗过牌。extract_words()能拿到每个词的x0、x1、top、bottom,有了坐标就能先分栏再排序。分栏的逻辑不复杂:同一栏里的词,x0波动范围小;栏与栏之间会出现明显的横向间隔。按页宽的百分比做粗聚类,比硬编码像素值更稳。
下面的代码先把每页的词坐标取出来,再按x0聚类分栏,最后按“栏内从上到下、从左到右”的顺序拼回文本。这里的gap_ratio是栏间距阈值,我一般从0.06开始试,名单页密集时调到0.04,正文页可以放到0.08。这个参数没有万能值,得看实际版面。
import pdfplumber from pathlib import Path def cluster_columns(words, page_width, gap_ratio=0.06): if not words: return [] # 按 x0 排序后,相邻词间距超过页宽阈值就切新栏 words = sorted(words, key=lambda w: (w["x0"], w["top"])) gap = page_width * gap_ratio columns, current = [], [words[0]] for w in words[1:]: if w["x0"] - current[-1]["x1"] > gap: columns.append(current) current = [w] else: current.append(w) columns.append(current) return columns def page_text_by_column(page): words = page.extract_words(use_text_flow=False, keep_blank_chars=False) columns = cluster_columns(words, page.width, gap_ratio=0.06) lines = [] for col in columns: col = sorted(col, key=lambda w: (round(w["top"], 1), w["x0"])) buf, last_top = [], None for w in col: if last_top is None or abs(w["top"] - last_top) <= 3: buf.append(w["text"]) else: lines.append("".join(buf)) buf = [w["text"]] last_top = w["top"] if buf: lines.append("".join(buf)) return "\n".join(lines) with pdfplumber.open("data_industry_map.pdf") as pdf: for i, page in enumerate(pdf.pages, start=1): txt = page_text_by_column(page) Path(f"work/page_text/page_{i:03d}_col.txt").write_text(txt, encoding="utf-8")这段代码的关键在cluster_columns:它不追求完美分栏,只解决“同一页两栏文字被交叉读取”的问题。gap_ratio越小,越容易把一栏切成两栏;越大,越容易把两栏并成一栏。名单页的阅读顺序是PDF提取里最玄学的一环,遇到串行先别怀疑代码,拿原文页对照一下栏间距,通常调一个参数就能回来。round(w["top"], 1)是为了让同一行的微小坐标差归到一行,避免一行被拆成好几段。
2.3 版面分栏与阅读顺序:名单页最容易串行
名单页的典型结构是“分类标题 + 多栏企业名”。分类标题通常横跨整页,企业名在下面分两栏或三栏排。纯文本抽取会按字符流顺序走,可能先读左栏第一行,再读右栏第一行,再回到左栏第二行,最后拼出来的名单看似完整,实际顺序全乱。顺序一乱,后面按行切分就会把两个企业名粘在一起,或者把一个企业名切成两半。
处理办法是先把页面切成“标题区”和“名单区”。标题区用横跨页宽的大字号或加粗文本识别,名单区再按栏切。标题区不一定能靠字体大小判断,PDF里字体信息经常丢,所以更稳的做法是看文本行宽度:横跨页宽80%以上的短行,大概率是分类标题;宽度只占一栏的短行,大概率是名单行。这个规则不绝对,但足够把大部分页面拆开。
如果页面是扫描件,分栏信息只能靠OCR的检测框拿。常见做法是用OCR返回的文本框坐标做同样的聚类,再按栏拼接。OCR选型上,Tesseract适合干净印刷体,PaddleOCR对中文版面更友好,但部署重一些。我的建议是:文本层可用的页面坚决不OCR,只有体检不过的页面才补,不要把整份PDF无脑丢进OCR。
2.4 输出中间态:每页一个txt加元数据
中间态要保留页码,这是后面抽样回查的“后悔药”。没有页码,发现问题只能整份重跑;有了页码,抽10%回查原文页,几分钟就能定位是抽取问题还是原文问题。中间态至少留三类文件:页级纯文本、页级分栏文本、页级元数据CSV。元数据里记页码、字符数、替换符数、是否OCR、分栏数,后面做质量报表直接读这张表。
import csv from pathlib import Path rows = [] for p in sorted(Path("work/page_text").glob("page_*_col.txt")): page_no = int(p.stem.split("_")[1]) text = p.read_text(encoding="utf-8") rows.append({ "page": page_no, "chars": len(text.replace("\n", "")), "lines": len([x for x in text.splitlines() if x.strip()]), "has_replacement": "\ufffd" in text or "□" in text, "source": str(p), }) with open("work/page_meta.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=rows[0].keys()) writer.writeheader() writer.writerows(rows)这张元数据表不参与最终统计,但它是排查的入口。比如某几页chars突然很低,先看has_replacement,再回原文页看是图片还是字体问题;某几页lines特别多,可能是分栏切得太碎,需要把gap_ratio调大。source字段保留文件路径,后面做增量更新时可以直接比对哪些页面变了。中间态不要嫌多,等你要给汇报材料找依据时,就知道这几张表比一句“我抽过了”有用得多。
3. 从名单段落抽结构化表:字段设计、分类映射与规则抽取
3.1 字段设计:别只存企业名
只存企业名的表,看着干净,用起来很痛苦。你没法回答“这个主体属于哪个一级类目”“它出现在第几页”“它是原文原样还是我改过的”。我一般至少留这些字段:原文名称、匹配键、一级类目、二级类目、区域线索、来源页、抽取方式、置信度、复核标记。原文名称不改,匹配键单独生成,后面去重和比对都用匹配键,不覆盖原值。
| 字段 | 作用 | 维护规则 |
|---|---|---|
| company_raw | 保留原文原样 | 不删括号、不改错字 |
| match_key | 去重和比对 | 去空白、统一括号、去后缀 |
| category_l1 | 一级类目 | 固定词典,不随意新增 |
| category_l2 | 二级类目 | 可留空,允许扩展 |
| region_hint | 区域线索 | 只做提示,不强行补全 |
| source_page | 来源页 | 必填,回查用 |
| extract_method | 抽取方式 | text/ocr/rule/manual |
| confidence | 置信度 | 规则给分,人工可改 |
| review_flag | 复核标记 | 0/1,默认0 |
另一个容易踩的坑是“一行一个主体”。原文里一个主体可能同时出现在数据资源企业和数据基础设施企业下面,按一行一个主体存,后面的类目会把前面的覆盖掉。更稳的结构是“主体-类目”长表:一个主体可以有多行,每行一个类目,统计时再按口径去重。这样你既能算“有多少个主体”,也能算“有多少个类目挂载”,两个数不是一回事,汇报时要说清楚。
3.2 分类映射:一级类目固定,二级类目留扩展位
一级类目直接跟原文的章节走:数据资源企业、数据应用企业、数据服务企业、数据技术企业、数据基础设施企业、交易中心/交易所。这六类不要临时发明,发明了后面汇总对不上原文。二级类目可以细,比如数据应用下面分城市治理、工业、交通、金融、医疗健康、商业、能源、农业等,这些词在原文里有线索,抽不到就留空,不要硬猜。
| 一级类目 | 判定线索 | 常见误判 |
|---|---|---|
| 数据资源企业 | 章节标题含“数据资源” | 把资源型公司和应用型公司混在一起 |
| 数据应用企业 | 章节标题含“数据应用” | 二级行业词被当成企业名 |
| 数据服务企业 | 章节标题含“数据服务” | 咨询、评估、经纪混为一类 |
| 数据技术企业 | 章节标题含“数据技术” | 技术平台和芯片公司边界模糊 |
| 数据基础设施企业 | 章节标题含“数据基础设施” | 云平台、算力、安全混在一起 |
| 交易中心/交易所 | 名称含“交易中心/交易所” | 当成普通企业统计 |
分类映射我一般用“词典加权规则”,不用模型硬猜。规则看三个位置:所在章节标题、名称后缀、上下文关键词。章节标题权重最高,名称后缀次之,上下文只做补充。比如名称里有“交易所”但章节在数据服务企业下面,也要单独标成交易中心/交易所,统计时分开。二级类目允许空,空着比猜错好;猜错了后面做行业分布,结论会偏得很难看。
3.3 用规则+词典抽企业名:正则边界与停用词
名单段落切分最怕两件事:一是分隔符不统一,有的用顿号,有的用逗号,有的用换行;二是括号里有逗号,直接按逗号切会把一个名字切成两半。我的做法是先按换行和明显分隔符切,再用括号配对做一次保护,最后用停用词表过滤章节标题和说明性短语。停用词表要包含“数据资源企业”“数据应用企业”“数据服务企业”“数据技术企业”“数据基础设施企业”这些标题词,不然它们会被当成企业名收进来。
import re STOP_WORDS = { "数据资源企业", "数据应用企业", "数据服务企业", "数据技术企业", "数据基础设施企业", "交易中心", "交易所", "城市治理", "工业大数据", "交通大数据", "金融大数据", "医疗健康大数据", "商业大数据", "能源大数据", "农业大数据", } SUFFIX_HINTS = ( "数据", "科技", "信息", "智能", "网络", "软件", "电子", "集团", "研究院", "中心", "事务所", "平台", "云计算", "半导体", ) def split_candidates(text): # 先按换行、顿号、逗号、分号切;括号内的逗号先保护 protected = re.sub(r"[((][^))]*[))]", lambda m: m.group(0).replace(",", "§").replace(",", "§"), text) parts = re.split(r"[\n、,,;;]|(?<!\d)\.(?!\d)", protected) return [p.replace("§", ",").strip() for p in parts if p.strip()] def keep_company(s): if not (2 <= len(s) <= 40): return False if s in STOP_WORDS: return False if re.fullmatch(r"[0-9\W]+", s): return False return any(x in s for x in SUFFIX_HINTS) raw = """数据资源企业 示例数据科技有限公司、某区域数据服务有限公司(研究院)、示例信息集团 数据应用企业 城市治理、工业大数据、示例智能科技有限公司""" candidates = [x for x in split_candidates(raw) if keep_company(x)] print(candidates)这段代码不是万能抽取器,它解决的是“段落里候选名太多、人工挑不过来”的问题。split_candidates先用占位符把括号里的逗号保护起来,切完再还原,避免把“某区域数据服务有限公司(研究院)”切成两段。keep_company用长度、停用词、后缀线索做粗筛,宁可多留一些进人工复核,也不要把明显不对的收进来。后缀线索是经验词典,不是官方规则,遇到新行业词直接往SUFFIX_HINTS里加,不要改主流程。
3.4 批量落表与抽样复核
批量落表时,每个候选名都要带上来源页和所在类目。来源页从文件名或元数据里取,类目从最近的章节标题继承。继承逻辑要处理“标题行”和“名单行”的区分:标题行通常短、不含后缀词、出现在页面顶部或段落开头;名单行通常含分隔符、长度更长。规则不可能百分之百准,所以落表后必须抽样复核。
import csv import re from pathlib import Path rows = [] for p in sorted(Path("work/page_text").glob("page_*_col.txt")): page_no = int(p.stem.split("_")[1]) current_l1 = "" for line in p.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line: continue # 标题行:短、命中一级类目词 if len(line) <= 12 and any(k in line for k in ["数据资源企业", "数据应用企业", "数据服务企业", "数据技术企业", "数据基础设施企业", "交易中心"]): current_l1 = line continue for cand in split_candidates(line): if keep_company(cand): rows.append({ "company_raw": cand, "category_l1": current_l1, "source_page": page_no, "extract_method": "rule", "confidence": 0.7, "review_flag": 0, }) with open("work/companies_raw.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=rows[0].keys()) writer.writeheader() writer.writerows(rows)落表后先看三张报表:总行数、空类目行数、每页行数分布。总行数突然比预期少,通常是切分规则太严;空类目行数多,通常是标题继承没接上;每页行数分布极端,通常是分栏顺序出了问题。抽样复核按10%抽,固定随机种子,保证每次可复现。复核时不要只看表,要回原文页看上下文,尤其是括号里带区域、带“研究院”、带“集团”的名字,这些是最容易归一化过头的。
4. 企业名称清洗与去重:括号、别名和同名不同主体的处理顺序
4.1 清洗顺序:先归一再匹配
清洗顺序错了,后面全是血泪。正确顺序是:保留原值、去不可见字符、统一括号、去多余空白、生成匹配键、分块、模糊匹配、人工复核。不要一上来就全量模糊匹配,也不要在原值上直接改括号和区域前缀。原值是证据,匹配键是工具,两者混在一起,回查时你都不知道自己改过什么。
import re def normalize_name(name: str) -> str: s = name.strip() s = s.replace("(", "(").replace(")", ")") s = re.sub(r"[\u200b\u200c\u200d\ufeff]", "", s) s = re.sub(r"\s+", "", s) s = re.sub(r"[·•]", "", s) return s def match_key(name: str) -> str: s = normalize_name(name) # 只在匹配键里尝试去常见后缀,原值不动 s = re.sub(r"(有限责任公司|有限公司|股份有限公司|集团)$", "", s) return s print(normalize_name("示例数据科技(某区域)有限公司")) print(match_key("示例数据科技有限公司"))normalize_name只做无害归一化:统一全半角括号、去零宽字符、去空白、去间隔点。match_key在归一化基础上再去常见后缀,目的是让“示例数据科技有限公司”和“示例数据科技(某区域)有限公司”能进同一个候选组。注意,去后缀只在匹配键上做,原值必须保留,否则后面回查原文页会对不上。区域前缀的去法要更谨慎,不要直接正则删“某省某市”,因为有些名字里的区域词是品牌的一部分,删了就变成另一个主体。
4.2 括号、后缀与区域前缀的处理
括号里可能是区域、业务说明、曾用名、分支机构,不能一刀切。我的处理是:括号内容保留在原值里,匹配键里按规则尝试去掉“区域型括号”,但保留“业务型括号”。区分方法看括号内是否命中行政区划词典或“分公司/研究院/中心”这类组织词。命中行政区划的,匹配键去掉;命中业务词的,匹配键保留。这个规则不完美,但比全删或全留都稳。
| 原值示例 | 匹配键示例 | 风险 |
|---|---|---|
| 示例数据科技(某区域)有限公司 | 示例数据科技 | 区域括号去掉后可能和外地主体混淆 |
| 示例数据科技(研究院)有限公司 | 示例数据科技研究院 | 业务括号保留,避免误合并 |
| 示例数据集团有限公司 | 示例数据 | 去集团后可能和子公司混淆 |
| 某区域示例数据服务有限公司 | 示例数据服务 | 区域前缀去掉后匹配面变大 |
后缀处理也一样,不要把所有“集团”都当成冗余。集团和子公司是不同主体,匹配键去“集团”只用于找候选,不用于直接合并。最终是否合并,要看来源页、类目、上下文和人工复核。同名不同主体在产业图谱里很常见,尤其是“某区域数据科技有限公司”这种结构,区域词一换就是另一家。我的原则是:匹配键只负责把候选拉进复核队列,不负责下结论。
4.3 用rapidfuzz做候选对,不要直接全量模糊匹配
全量模糊匹配的复杂度是平方级,几千条还能跑,几万条就等得难受,而且结果里全是低分噪声。更稳的做法是先分块,再在块内匹配。分块键可以用匹配键前两个字、后缀词、区域线索,任意一个能缩小范围都行。块内用rapidfuzz的token_set_ratio,它对词序和重复词更宽容,适合公司名这种带括号和后缀的文本。
from rapidfuzz import fuzz, process names = [ "示例数据科技有限公司", "示例数据科技(某区域)有限公司", "示例数据服务有限公司", "某区域示例智能科技有限公司", ] keys = [match_key(x) for x in names] # 先按前两个字分块,块内再模糊匹配 blocks = {} for i, k in enumerate(keys): blocks.setdefault(k[:2], []).append(i) pairs = [] for block in blocks.values(): for i in block: for j in block: if i >= j: continue score = fuzz.token_set_ratio(keys[i], keys[j]) if score >= 90: pairs.append((names[i], names[j], round(score, 1))) for p in pairs: print(p)这段代码故意把阈值放在90,不是为了自动合并,而是为了控制复核量。90分以上的候选对通常已经很像,85到90之间留给人工判断。token_set_ratio对“示例数据科技”和“示例数据科技(某区域)”这类包含关系比较友好,但对“示例数据科技”和“示例数据服务”这种只差一个词的,也会给不低的分,所以不能只看分数。分块键用前两个字不是唯一解,也可以按后缀词分块,或者按区域线索分块,目标都是把候选对从全量平方降到可接受范围。
4.4 人工复核队列怎么排
复核队列要排序,不要从第一行开始硬看。优先级最高的信号是:分数在85到95之间、长度差超过6个字、括号内容不同、跨一级类目。这四类最容易出“看似同一家、实际不同主体”的情况。优先级最低的是分数98以上且后缀一致、来源页相邻、类目相同的候选对,这些可以直接标“高置信”,抽几组确认就行。
| 优先级 | 信号 | 处理动作 |
|---|---|---|
| 高 | 分数85-95,长度差>6 | 回查来源页,看上下文 |
| 高 | 括号内容不同 | 判断是区域、业务还是曾用名 |
| 高 | 跨一级类目 | 保留多类目,不强行合并 |
| 中 | 分数95-98,后缀不同 | 看是否集团/子公司关系 |
| 低 | 分数>98,后缀一致 | 抽检后标高置信 |
复核时建议加两列:merge_decision和merge_note。merge_decision只填“合并/不合并/待定”,merge_note写依据,比如“来源页不同,括号内为业务说明,保留两条”。不要只改匹配键不留痕,过两周你自己都不记得为什么合了。人工复核不是一次性工作,图谱更新后还要复用这套队列,所以规则和备注要能传给下一个人。
5. 避坑与复核:五类翻车现场和一套验收脚本
5.1 现象:Ctrl+F能搜到,抽出来却乱序或断字
现象很典型:PDF阅读器里搜索“数据”能命中,抽成文本后名单顺序乱了,或者“数据资源”变成“数 据 资 源”。原因通常不是OCR,而是文本层里的字距、分栏和文本框顺序。PDF的字符流顺序和视觉阅读顺序本来就不一回事,名单页多栏排版时尤其明显。解决顺序是:先调x_tolerance和y_tolerance,再用词坐标分栏,最后才考虑OCR。调参时不要一次改多个,先改x_tolerance看断字是否减少,再改gap_ratio看串行是否回来。如果分栏后仍然乱,回原文页看是不是有隐藏文本框或页眉页脚干扰,把页眉页脚先裁掉再抽。
5.2 现象:同一主体出现多次,汇总数虚高
现象是总行数看着不少,去重后少了一截,或者同一个主体在不同类目下各算一次,汇报时数对不上。原因通常是括号区域、后缀差异、换行截断三件事叠加。比如原文里一个名字被换行切成两行,第二行只剩“有限公司”,规则抽取时可能把它当成独立候选;再比如同一主体在数据资源企业和数据基础设施企业下各出现一次,长表里就是两行。解决办法是保留“主体-类目”长表,统计时明确口径:按主体去重算一家,按类目挂载算多条。去重时先用匹配键分块,再模糊匹配,最后人工复核,不要直接drop_duplicates原值,那样括号一改就漏。
5.3 现象:一级类目汇总对不上原文
现象是六个一级类目加起来的数量比原文名单少,或者某个类目突然多出一截。原因多半是标题继承断了:名单页跨页时,第二页开头没有重复标题,抽取脚本把第二页的名单挂到了空类目或上一页最后一个类目。解决办法是维护一个“跨页类目状态”,每页开始时先找页面顶部的标题行,找不到就继承上一页最后一个类目,同时在元数据里记category_inherited标记。汇总前跑一次空类目检查,空类目行数超过5%就停下来看,不要带着空类目做统计。
5.4 现象:OCR把“数据”认成“数搪”,名单少一截
现象是扫描页OCR后出现一批奇怪的名字,比如“数搪”“数椐”,或者整段名单只出来一半。原因是扫描分辨率不够、字体太细、页面有底纹,OCR把形近字认错。解决办法是先把扫描页提到300dpi,做灰度化和二值化,再用中文词典做后纠错。纠错不要全局替换,只对高频错误词做定向替换,比如把“数搪”改回“数据”,改完必须抽原文页核对。OCR结果一定要保留extract_method=ocr,后面统计时单独看OCR页的准确率,不要和文本层抽取混在一起算。
5.5 现象:把交易中心当普通企业统计
现象是“企业总数”里混进了交易中心、交易所,做赛道分布时这些主体被算进数据服务企业,结论偏得很奇怪。原因是交易中心在原文里常和 data 服务企业排在一起,名称里又有“数据”“服务”字样,规则容易误收。解决办法是单独建“交易中心/交易所”一级类目,名称命中“交易中心”“交易所”就优先归到这一类,不再参与普通企业统计。如果原文确实把它列在数据服务企业下面,保留一条普通类目记录,同时加一条交易中心标记,统计时按口径选择,不要直接删。
5.6 一套可重复的验收脚本
验收不要靠肉眼,写个小脚本,每次跑完抽取都输出同一组指标。重点看总行数、空主体名、重复主体名、无来源页、一级类目数、抽样复核文件是否生成。指标不追求绝对漂亮,追求“每次都能对比”。这次总行数比上次少20%,你就知道要回去看切分规则;空来源页突然变多,说明页码继承断了。
import pandas as pd df = pd.read_csv("work/companies_raw.csv") checks = { "总行数": len(df), "空主体名": int(df["company_raw"].isna().sum()), "重复主体名": int(df["company_raw"].duplicated().sum()), "无来源页": int(df["source_page"].isna().sum()), "一级类目数": int(df["category_l1"].nunique()), } print(checks) # 固定随机种子,保证每次抽到同一批,方便对比 sample = df.sample(frac=0.1, random_state=42) sample.to_csv("work/review_sample.csv", index=False, encoding="utf-8-sig")frac=0.1是抽样比例,名单量大时可以降到0.05,量小时提到0.2。random_state=42不是玄学,是为了让每次验收抽到同一批,改规则后能直接对比差异。验收表里再加一列“复核结论”,人工填“通过/不通过/待查”,下次更新图谱时先看上次不通过的样本有没有复发。验收脚本不解决抽取问题,但它能把“感觉差不多”变成“这次和上次差在哪”。
6. 进阶用法:把图谱做成可增量复用的产业底稿
这份PDF最大的价值不是一次性统计,而是做成可增量复用的底稿。我的做法是维护一张最小表:主体原值、匹配键、能力标签、来源页。四列就够用,后面所有分析都从这张表派生。主体原值不动,匹配键用于跨版本比对,能力标签存一级类目和二级类目,来源页保证可回查。每次拿到新版图谱,不重新造表,先跑抽取,再用匹配键做集合差,输出新增、删除、改名三类清单。
| 字段 | 作用 | 维护规则 |
|---|---|---|
| 主体原值 | 证据 | 不修改,保留括号和错字 |
| 匹配键 | 版本比对 | 归一化后生成,不覆盖原值 |
| 能力标签 | 分类分析 | 一级固定,二级可扩展 |
| 来源页 | 回查 | 必填,OCR页单独标记 |
增量比对时,先看新增主体集中在哪些类目,再看删除主体是不是改名而不是真的消失。改名判断用匹配键相似度加来源页上下文,分数高且类目一致的,标“疑似改名”,不要直接当新增。每次比对完抽10%回查原文页,重点看跨类主体和交易中心,这两类最容易在版本之间换位置。从那以后我每次拿到这类PDF图谱,都先跑页级文本体检,再抽10%回查原文页,最后才敢把汇总数放进汇报材料。希望帮到你。
本文还有配套的精品资源,点击获取