简介:专精特新‘小巨人’企业财务数据资源包覆盖2013至2024年,整合第1至第5批国家级专精特新‘小巨人’企业的财务与名单信息,面向经济学研究者、政策制定者、投资分析人员及企业战略规划者,可用于分析中小企业成长路径、行业分布特点、政策扶持效果与区域创新格局。资源包共10个文件,主要包含Stata可直接读取的dta数据文件、Excel格式的xlsx财务数据表、数据来源与处理说明html、使用说明txt,以及6个PDF格式的分批次企业名单文件,整体体积36.12MB,兼顾统计分析、表格浏览与资料查证需求。目前已有55人学习下载。借助这份数据,读者可一站式获取企业营收、利润、资产负债等财务指标及批次、地区、行业等关键属性,免去多处收集、手工清洗的繁琐;既可用于学术计量分析与政策评估,也可为投资筛选、企业对标提供参考,是观察中国经济微观层面、尤其是专精特新中小企业发展的重要资料。
1. 这份 zip 不是开箱即用的数据,先看货再动手
做企业授信尽调或产业研报的人,看到“专精特新小巨人-财务数据(第1-5批)(2013-2024年).zip”这种文件名,第一反应基本是:终于不用自己去翻五年名单了。但真正解压之后,才会发现压缩包标题里的“第1-5批”和“2013-2024年”只是最乐观的描述——它并不承诺每个批次都覆盖每一年,也不保证五批表的字段长一个样。这篇笔记就把这类小巨人财务数据包从头到尾拆一遍:先做文件地图,再统一字段和主键,然后算财务指标、做候选池筛选,最后把最容易让分析翻车的几个坑列清楚。适合把这包数据当“候选池”而不是“结果集”的人,尤其是做企业融资、产业跟踪和基础风控建模的从业者。
2. 拆包与目录结构:先看清批次构成,再谈清洗
2.1 用 infolist 列出压缩包内容,先做文件地图
拿到这种压缩包,我的习惯是暂缓一切读取动作,先做一次“只看不动”的文件清单检查。原因很简单:第1批和第5批之间隔了好几年,中间换过文件名模板、补过数据,压缩包里的实际文件数很可能和标题暗示的不一致。直接 pd.read_csv 去猜文件路径,是最容易踩空的姿势。想把这个包变成能持续使用的数据资产,第一优先是把 zip 的目录完整读出来,这步比任何清洗代码都重要。
import zipfile from pathlib import Path zip_path = Path("专精特新小巨人-财务数据(第1-5批)(2013-2024年).zip") with zipfile.ZipFile(zip_path, "r") as zf: for info in zf.infolist(): print(info.filename) print(f" 原始大小: {info.file_size} 压缩后: {info.compress_size}")这段代码输出的信息足够判断三件事:文件是按批次拆分还是按年份拆分,有没有“补录”“更新”字样的文件,以及单个 CSV 是否大到异常。file_size是未解压的原始体积,compress_size是压缩包内占用体积。文本型 CSV 重复内容多,压缩率一般很高;如果一个“基本信息表”解压出来有几十 MB,那里面很可能不只是财务字段,而是把明细账也塞了进来,后面解析时要按大文件单独处理,不能和普通表格走同一套逻辑。
还有一类容易被忽略的细节:zip 里的文件名用什么编码写入。打包工具在不同操作系统里生成的文件头编码不一致,Python 的 zipfile 默认按 cp437 解码,中文文件名经常变成乱码。虽然不影响文件内容读取,却会让文件地图完全不可读。我一般会在列目录时挂一个名字修正函数,先把乱码字符还原成原始字节,再用 gbk 重新解释。
def fix_zip_name(raw: str) -> str: try: return raw.encode("cp437").decode("gbk") except (UnicodeEncodeError, UnicodeDecodeError): return raw这段函数简短,作用却很关键:encode("cp437")是把当前显示出的乱码文字还原成原始字节,decode("gbk")再按中文系统常见的编码去解读,文件名就能恢复成可读文本。异常分支的作用是兜底,遇到本来就是英文或数字的纯 ASCII 文件名,decode 会报错,这时直接用原始名字。这一步处理完之后,再去匹配公司名单、做后续字段合并,才不会被编码不一致搞出两套表。
2.2 批次与年份口径:先验证是不是平衡面板,再合并数据
文件清单确认之后,下一个问题更关键:这些财务年份到底是不是连续覆盖的。所谓平衡面板,是指每个企业从 2013 到 2024 每年都有记录。但实际拿到的数据大多是滚动子集:第1批企业可能从 2013 或更早开始有财务数据,第4批、第5批企业很多是近年才达到相关资质门槛,它们没有早期年份的财务记录是正常的。
我会在合并之前,先对每一个批次单独做一次“年份取值检查”,确认该批数据能覆盖哪些年度。
import pandas as pd df = pd.read_csv( "第1批_财务数据(2013-2024).csv", encoding="utf-8", dtype={"统计年份": str}, ) year_counts = df["统计年份"].value_counts().sort_index() print(year_counts)把“统计年份”读成字符串而不是数字,是刻意为之。CSV 里像 2013 这样的年份,有时候会被 Excel 或某些导出工具写成日期格式,进入 pandas 后变成“2013-01-01”,后续做年份排序和匹配都会出问题。先用字符串读入,再在明确需要做年份运算时统一转 int,是最稳妥的流程。
执行这段代码之后,我会看两个指标:第一个是最小年份和最大年份,第二个是每年企业计数是否出现断裂。如果输出为“2013: 120, 2014: 131, 2015: 0, 2016: 118”,说明该批次缺失 2015 年数据,后面做同比计算时要特别留意;如果本身就是截面数据,可能只有文件标题上的一个年度,那就更不能用它去做逐年趋势分析。
2.3 跨批次同名企业:先做名称归一,再去判断重复
五批名单里,同一家企业出现在两个批次里是完全正常的,很多企业是第一批认定之后,又出现在后面批次的补充名单里。但在数据处理上,这属于“重复样本超集”,如果直接把五份表 concat 到一起,等于把某家企业的财务记录复制多份,后面所有汇总统计都会失真。
处理重复的第一步不是去重,而是先做企业名称归一化。常见做法是把全角括号统一成半角,去掉名称首尾空格,再把“有限责任公司”这类后缀从比较逻辑里暂时剔除,因为不同批次的表可能一个写“某某科技(北京)有限公司”,另一个写“某某科技(北京)股份有限公司”。
def normalize_company_name(name: str) -> str: if not isinstance(name, str): return "" name = name.strip() name = name.replace("(", "(").replace(")", ")") name = name.replace("股份有限公司", "") name = name.replace("有限责任公司", "") return name df["企业标识"] = df["公司名称"].map(normalize_company_name) print(df.groupby("企业标识")["批次"].nunique().value_counts())replace("股份有限公司", "")这一步会损失精度,所以只能在“识别同一家企业”时用,不能用它做最终展示名。groupby之后看“批次”字段的非重复计数,能直观看到一家企业出现在几个批次里。计数等于 1 的是本批独有,大于 1 的是跨批次企业。真正的去重规则要在数据合并阶段再定,我常用的方案是:保留批次编号最大的那条记录,也就是最新一批的财务明细,同时把历次出现的批次号合并成一个列表字段,方便追溯。
3. 字段统一与合并:把五批表合成一张可用宽表
3.1 表头别名太多,用映射字典统一字段名
五批文件来自不同时期,表头字段名经常对不齐。同一个含义,这一批叫“营业收入”,下一批叫“营业总收入”,再下一批可能叫“营收”。直接 concat 的后果是同一列被拆成三列,其中两列全是空值;等到做指标计算时,数据量凭空少了一大块。
我的做法是先写一张字段映射表,把每个标准字段名对应的别名列出来,再用一个统一函数处理所有读取进来的 DataFrame。
field_map = { "企业名称": ["企业名称", "公司全称", "公司名称"], "统计年度": ["统计年度", "年度", "会计年度", "年份"], "营业收入": ["营业收入", "营业总收入", "营收"], "净利润": ["净利润", "归母净利润", "净利"], "总资产": ["资产总计", "总资产", "期末资产"], "总负债": ["负债合计", "总负债", "期末负债"], } def normalize_columns(df: pd.DataFrame) -> pd.DataFrame: col_mapping = {} for std_name, alias_list in field_map.items(): for col in df.columns: if col in alias_list: col_mapping[col] = std_name return df.rename(columns=col_mapping)这段逻辑里有两个关键细节。第一个是映射方向:判断时用“数据里的列名是否出现在别名列表”,而不是反向判断,因为一个别名可能出现在多个标准字段下,反向判断容易冲突。第二个是返回值,我只做 rename 不裁剪列,这样万一某批文件里藏着没见过的自定义字段,还能保留下来供人工确认。如果确认这个字段没用,再在后续处理里显式删除,别在统一阶段悄悄丢掉信息。
3.2 设计稳定主键:企业标识加统计年度
字段统一之后,下一步是构造主键。主键的作用是给每一行一个唯一身份,之后检查重复、匹配外部数据、做透视表都依赖它。对这个数据集,最合理的主键是企业标识加统计年度,因为财务数据表里一个企业一年应该只有一条有效记录。
df_all["主键"] = df_all["企业标识"] + "_" + df_all["统计年度"].astype(str) mask_dup = df_all.duplicated(subset=["主键"], keep=False) print(df_all.loc[mask_dup].sort_values("主键").head(10))duplicated(keep=False)会把所有重复行都标出来,而不只是保留第一条。这样能直接看到完整冲突记录,而不是被默认行为悄悄吃掉一半。如果发现主键重复,我先不急着删除,而是去看重复的两行到底来自哪个批次。通常有两种情况:一种是同一批次里自身就重复,那多半是原始表有错,需要人工确认后删除;另一种是不同批次都收录了同一年数据,这时按前面说的规则保留最新批次。
3.3 用年度跨度判断长面板可用性
主键确认干净后,可以进一步统计每家企业的年份跨度和记录年数。这个指标决定你能做什么层级的分析。如果一批企业普遍只有一两年财务记录,就别想做严格的面板回归;如果记录有七八年甚至更长,才有资格进入趋势类和增长类分析。
span = df_all.groupby("企业标识").agg( 首年=("统计年度", "min"), 末年=("统计年度", "max"), 年数=("统计年度", "nunique"), ) print(span.describe())nunique统计的是不同年份数量,比count更严谨。count会把同一年的重复行也算进去,而nunique能准确告诉你覆盖了几年。首年和末年的差值能看出时间跨度,年数能看出记录完整度。一家企业首年 2013、末年 2024、年数只有 5,说明中间缺了好几年,这种样本在做时间序列时要格外小心,最好单独标记成“间隔面板”。
看到描述性统计之后,我会先在 data 里加一个“记录年数”字段,后面做企业筛选时直接用,避免反复 groupby。这也是长期维护数据集的好习惯:把高频使用的统计结果落成字段,而不是每次现算。
3.4 长表和宽表怎么选:先算指标,再决定是否透视
很多同事习惯一开始就把数据转成宽表,即每家企业一行、每个年份一列,觉得这样看起来整齐。但对这种跨十二年且年份不齐的数据,过早转宽表会制造大量空值,一张几万行的长表,透视后可能变成几百万个空单元格。
我的建议是:在指标计算阶段保持长表,需要做矩阵式对比时再按需透视。
wide = df_all.pivot_table( index="企业标识", columns="统计年度", values="营业收入", aggfunc="sum", ) missing_ratio = wide.isna().mean(axis=1) print(missing_ratio.describe())这段代码的作用不是做最终展示,而是快速估计“每家企业在年份维度上的缺失比例”。isna().mean(axis=1)对每一行求缺失率,然后看整体分布。如果缺失率中位数超过 0.5,说明大多数企业只有一半甚至更少的年份有数据,这时候趋势分析要降级为“局部年份分析”,或者干脆放弃连续增长类指标,只做截面财务质量评估。
4. 财务指标计算与候选企业筛选:拉通数据后先组指标
4.1 计算同比和资产负债率:先按企业分组,再做行内计算
长表整理好后,就可以开始计算财务指标。最容易翻车的写法是不排序直接算,或者把不同企业混在同一组里做 diff。正确的逻辑是先按“企业标识”分组,再在组内按“统计年度”排序,最后才做环比或同比计算。
df_all = df_all.sort_values(["企业标识", "统计年度"]).copy() df_all["营收同比"] = df_all.groupby("企业标识")["营业收入"].pct_change() df_all["资产负债率"] = df_all["总负债"] / df_all["总资产"]pct_change()在 groupby 之后默认就是与上一行比,加上前面已经按年份排序,得到的就是严格意义上的同比增速。第一年因为没有上一期数据,计算结果会是 NaN,这个不用删,后续分析时按需填充。资产负债率是当期总负债除以总资产,出现大于 1 的值要警惕,不代表数据错,可能确实是负资产企业,但这类样本需要单独标记。
在算这类指标时,我会先看两个分布:营收同比的 NaN 比例、资产负债率的极值情况。如果 NaN 比例超过一半,说明绝大多数企业缺失首年数据,后面做“连续增长”筛选时门槛必须放宽,否则候选池会缩水到没法用。
4.2 三年财务记录作为可分析底线
财务分析里有个不成文的底线:至少要有三年记录才谈得上“趋势”。两年数据虽然能算出一个同比增速,但单点增速受基数影响太大,做不了稳健判断。对这批数据,我会用“记录年数 >= 3”做第一道筛选。
df_all["记录年数"] = df_all.groupby("企业标识")["统计年度"].transform("nunique") df_pool = df_all[df_all["记录年数"] >= 3].copy() print(df_pool["企业标识"].nunique())用transform而不是agg,是为了把统计结果保持在同一张表的行粒度,后面筛选条件直接写在原表上,不用再合并一次。nunique前面已经解释过,能避免同一年重复记录带来的虚高。做完这一步,候选池就从“所有出现在名单里的企业”缩到了“财务记录足够支撑分析的企业”。
4.3 从“增长但亏损”的筛选逻辑到分层处理
做完基础门槛之后,就开始碰业务逻辑了。常见的初筛条件是最近一年净利润为正、资产负债率不过高、最近一年营收同比正增长。这三个条件对应的其实是三组不同风险维度:盈利质量、偿债压力、成长动能。
latest = df_pool.sort_values("统计年度").groupby("企业标识").tail(1) candidate = latest[ (latest["净利润"] > 0) & (latest["资产负债率"] < 0.7) & (latest["营收同比"].fillna(0) > 0) ] print(candidate["企业标识"].nunique())tail(1)在 groupby 后取的是每组最后一行,配合前面按年份排序,拿到的就是“最近一年”的数据。三个过滤条件都是比较保守的阈值:净利润大于 0 排除亏损企业;资产负债率小于 0.7 排除高杠杆样本;营收同比大于 0 排除萎缩企业。实际使用中阈值应该根据项目风险偏好调整,激进策略可以把资产负债率放宽到 0.85,保守策略可以要求连续两年营收正增长。
这一步的作用是把几千家企业压到几十上百家,让业务人员有精力去做更深度的调研。财务数据包本身解决不了“哪家企业真的值得投”,它能做的是快速告诉你“哪些企业从数据上看还没出问题”,把人的精力集中到真正需要判断的地方。
5. 数据包里的 5 个避坑现场:现象、原因、解决
5.1 金额单位不统一,同一列里万元与元并存
现象:把“营业收入”列做排序,头部突然出现一批营收只有几百块的“小企业”,单个指标和公司体量明显不匹配。原因:源表在采集时没有统一口径,早期批次按“元”导出,后期批次按“万元”导出,合表之后同一列里混了两种量纲。解决:先看列的中位数,如果中位数在千元以下,基本可以判断单位是“万元”,整体乘以 10000 转回“元”。
med = df_all["营业收入"].dropna().median() if med < 1000: df_all["营业收入"] = df_all["营业收入"] * 10000 df_all["营业收入"] = pd.to_numeric(df_all["营业收入"], errors="coerce")用中位数而不是均值判断,是因为财务数据分布极度右偏,头部几家大企业会把均值抬高,但中位数对异常值不敏感,能更真实反映多数样本的量级。errors="coerce"会把非数值内容转成 NaN,等于顺手清理掉“--”之类的脏值。
5.2 空值以“--”或“-9999”填充,统计指标直接崩
现象:计算净利润均值时出现一个极端负数,或者某些企业净利润显示为负 9999,导致整组统计量失真。原因:数据导出时用特殊字符串或代码值代替缺失,比如缺失填“--”、异常填“-9999”,数据读取时这些值被当成普通字符串或数值处理。解决:统一替换成 NaN,再转成数值类型。
df_all["净利润"] = df_all["净利润"].astype(str) df_all["净利润"] = df_all["净利润"].replace({"--": pd.NA, "None": pd.NA, "-9999": pd.NA}) df_all["净利润"] = pd.to_numeric(df_all["净利润"], errors="coerce")先把列转成字符串再替换,比直接替换更稳,因为原始数据里这些占位值可能以不同形式出现,有的是字符串,有的是数字。替换顺序也有讲究:先转字符串,再 replace,再 to_numeric,这样三步走,任何非常规的内容都不会漏掉。处理完以后要重新看一次缺失率,确认 NaN 占比是否在可接受范围内。
5.3 三季报混进年度数据,年末对比失真
现象:某企业“总资产”在 2019 年还是 1 亿级别,2020 年突然变成 4 亿,同比计算时出现异常跳变。原因:文件里混入的不是年报数据,而是三季报或半年报数据,不同报告期的资产规模天然不具备可比性。解决:看数据里是否有“报告期”或“季度”字段,如果存在,先筛选出 Q4 或年报记录再计算。
if "报告期" in df_all.columns: df_annual = df_all[df_all["报告期"].astype(str).str.contains("年报|Q4|12-31", na=False)] else: df_annual = df_all[df_all["统计年度"].astype(str).str.contains("12-31", na=False)]这里的处理逻辑是“能精确就精确,不能精确就按日期尾串筛选”。字段名每个版本不一样,我先确认字段存在性再执行,避免报错。筛选之后还要复查一遍年份跨度,确认数据分布没有因为筛选产生新的断层。
5.4 企业改名后重复统计,名称清洗只能救一半
现象:同一家公司在表里出现两条记录,但名称差了几个字,按“企业名称”去重对不上。原因:企业进行过工商变更,名称从“某科技有限公司”改成“某智能科技股份有限公司”,两个批次各采了一个版本。解决:如果数据里有统一社会信用代码或注册号,优先用它做去重;如果没有,只能靠“去掉后缀 + 保留核心字号”的方式做近似匹配。
df_all["核心标识"] = ( df_all["企业名称"] .astype(str) .str.replace(r"(.*?)|\(.*?\)", "", regex=True) .str.replace("股份有限公司|有限责任公司|有限公司", "", regex=True) .str.strip() )这段正则会把括号里的地区信息去掉,再把公司后缀去掉,留下“某智能科技”这样的核心字号。但这段代码不能保证百分百准确,两家不同企业可能共享核心字号,所以它只适合用来“标记疑似重复”,不能直接做删除依据。正确流程是先删除完全重复主键的行,再对疑似重复名单做人工抽查。
5.5 表尾多出“合计”和“平均”汇总行
现象:统计营收分布时,最大值高到离谱,或者某个行业合计金额远超十家头部企业的总和。原因:CSV 文件末尾自带合计行和平均行,读表时被当成普通企业样本。解决:按企业名称列过滤掉包含“合计”“平均”“总计”字样的行。
summary_keywords = ["合计", "平均", "总计", "小计"] mask = df_all["企业名称"].astype(str).str.contains("|".join(summary_keywords), na=False) df_all = df_all[~mask]na=False很关键,它能保证企业名称为空的行不会被 str.contains 误判删除,也不会报错。过滤时机放在字段统一之后、主键检查之前,因为汇总行也会参与主键构造,如果晚一步处理,主键里会出现“合计_2019”这种脏记录。过滤完之后,最好重新打印一次行数和各年度分布,确认没有把正常企业误伤。
6. 进阶用法:把这份数据做成“企业可用性档案”
数据清洗和指标计算完成之后,最后一步我建议做一张“企业可用性档案”,用结构化字段记录每家企业有多少年财务记录、最近一次财务年度是哪一年、最近年度营收和净利润各是多少。这张档案表的价值在于:不需要每次分析都重新读原始数据,也不需要反复跑 groupby,业务侧要一份名单时,直接基于档案做一次简单过滤就有结果。
def build_profile(g: pd.DataFrame) -> pd.Series: g = g.sort_values("统计年度") last = g.iloc[-1] return pd.Series({ "年数": g["统计年度"].nunique(), "末次年度": last["统计年度"], "最近营收": last["营业收入"], "最近净利润": last["净利润"], "最近资产负债率": last["资产负债率"], }) profile = df_all.groupby("企业标识").apply(build_profile) profile["活跃池"] = ( (profile["年数"] >= 3) & (profile["末次年度"].astype(int) >= 2023) )groupby之后apply返回的 DataFrame 会以“企业标识”为索引,每行是一个企业的完整档案。活跃池这一列的含义是:财务记录至少三年、且最近一次财务年度在 2023 年及以后。满足条件的企业可以进入持续观察名单,不满足的只能作为历史样本或补充参考。
这份档案还可以再做一层简单验证:抽查三到五家企业,把档案里的“最近营收”和原始批次表里的对应记录做人工比对。我通常在项目开始时花十分钟做这件小事,它能确认前面所有清洗逻辑没有把某个字段值改错。很多人觉得数据清洗做完就结束了,实际上最危险的是做完后从不验证。
以前我在一次模拟项目里,跳过验证直接用合并后的表建模,结果发现“最近营收”列里有几千个值被单位换算翻了一万倍,模型效果离谱到没法向业务解释。后来学到的教训是:数据包的完整与否,永远要用“抽查原始文件”来验证,光看统计特征看不出这种错误。现在无论多赶,我都会在交付前留一道人工核对步骤。希望这个习惯和这套处理流程,能帮你在拿到类似压缩包时少走一点弯路。
本文还有配套的精品资源,点击获取