import pandas as pd
import numpy as np在经管类实证研究中,“数据来源与样本选择”通常位于“研究设计”部分的开头,目的是告诉读者数据从何而来、样本如何构建,为后续实证分析提供基础。这部分流程已经形成一定惯例,但在实际操作中,很多研究者只关注“步骤”,而忽略了其背后的逻辑。本文尝试从因果推断角度,结合回复审稿人的经验,梳理常规流程背后的逻辑,并提供一套 Python 实现方法。
1 样本选择逻辑服务因果推断
样本选择的核心目标是构建一个能够支持因果推断的反事实架构。而目前很多文章陷入了一种“标准化误区”:机械地堆砌剔除步骤,却不解释这些操作如何排除竞争性解释。例如,许多文章会写:
本文选取 2016−2019 年中国 A 股上市公司作为初始研究样本,在此基础上进一步对样本做了以下处理:(1)剔除 ST 和 ST*上市公司;(2)剔除金融业和保险业上市公司;(3)剔除上市不足 1 年的公司样本;(4)剔除回归变量缺失的样本;(5)对所有连续变量进行上下 1% 的缩尾。经过处理,本文最终得到 XXX 个“公司−年份”观测值。
这种写法虽然符合惯例,但在严谨的因果推断视角下,每个步骤都应具备识别逻辑。以“时间区间”的选择为例:为什么是 2016-2019?上述写作未说明原因。在“准自然实验”设计中,时间窗口的长度直接决定了识别的纯净度:如果选择的时间窗口过长,可能会引入其他外生冲击,影响因果推断的准确性;如果时间窗口过短,可能无法充分捕捉制度冲击的影响。以“样本剔除”的逻辑为例: 剔除 ST 并非只是为了“排除异常财务值”,本质上是为了确保个体同质性。ST 公司可能处于破产边缘,其决策逻辑(如风险偏好)与正常公司完全不同,将它们纳入对比会干扰对处理效应(Treatment Effect)的捕捉。
2 常规流程背后的逻辑
总结一下目前实证文章中“数据来源与样本选择”中的常规流程,背后的逻辑,以及存在的问题:
(1)选择 A 股上市公司作为研究样本
A 股市场具有较高的信息透明度和监管水平,有助于确保数据的可靠性和代表性。
(2)剔除 ST 和 ST* 上市公司
ST 和 ST* 公司通常面临财务困境和异常经营状态,且面临交易限制和风险警示,其财务数据可能不具有代表性,容易引入噪音,影响因果推断的准确性。这里的一个问题在于,如果样本股票在研究期限内被标记为 ST 或 ST,是剔除该股票的所有年份样本,还是仅剔除被标记为 ST 或 ST 的年份样本?从因果推断的角度,通常是剔除被标记为 ST 或 ST* 的特定年份观测值。如果因为某一年就剔除该公司整个历史序列,会造成严重的预知偏差(look-ahead bias)和幸存者偏差(survivorship bias)。
预知偏差是指在分析的历史时间点上,使用了当时尚不可知的未来信息。比如公司 2019 年因为经营不善被标记为ST,如果在分析 2016 年的数据时就剔除该公司,就相当于利用了2019年的信息,导致结果偏离真实的因果关系。幸存者偏差是指仅分析那些“幸存”下来的样本,而忽略了那些在研究期间退出的个体,导致样本不具代表性。比如要研究某项政策对企业绩效的影响,如果只分析那些在政策实施后仍然存续的企业,而忽略了那些因为政策影响而倒闭的企业,就会高估政策的积极效果。
需要注意的是,剔除特定年份的 ST 观测值会导致公司观测序列在时间上出现“非随机断裂”。针对此类断层,应避免使用简单的线性插值法进行数据补全。理由在于:一是 ST 期间企业通常经历剧烈的结构性变动,插值法会人为抹平财务波动,产生严重的测量误差并引入预知偏差;二是大多数计量软件(如 Stata 的 xtreg 或 Python 的 linearmodels)都能很好地处理不平衡面板数据。
(3)剔除金融业和保险业上市公司
金融性公司与非金融性公司的经营模式和财务结构存在显著差异,可能会引入行业特定的混杂变量,影响因果推断的有效性。
(4)剔除上市不足 1 年的公司样本
新上市公司信息披露、融资行为尚未稳定,可能会对因果关系的识别产生干扰,因此需要剔除。
(5)剔除变量严重缺失的样本
这里的问题在于“严重缺失”如何定义?“严重缺失”的定义应警惕非随机缺失(Missing Not at Random,MNAR)。惯例做法包括剔除连续缺失3年及以上数据的样本(丁宁和吴晓,2023;黄先海等,2024)。为什么选择 3 年作为阈值?个人认为有两个方面的考量:一是数据质量。连续缺失 3 年及以上数据的样本,在现实中几乎不可能是完全随机缺失(Missing completely at Random,MCAR)。出现这种情况,往往意味着公司存在长期信息披露异常,持续经营或治理存在问题等(情况不好我就不公布了🤣)。二是样本稳定性问题。从面板回归或者准自然实验的视角来看,单年缺失可以理解为噪声,连续缺失本质是样本进入-退出机制。连续 3 年缺失,往往伴随着长期停牌、财务报告异常、重组/借壳、监管处罚等,这些都会改变企业的融资行为、披露激励和治理结构等。也就是说,这类公司已经不再遵循与研究对象相同的决策函数。
(6)对所有连续变量进行上下 1% 的缩尾
缩尾处理有助于减少极端值对回归结果的影响,提高因果推断的稳健性。
3 Python 实现
从技术层面,本文认为首先应完成整体样本构建,包括:
- 从全样本筛选沪深 A 股上市公司
- 剔除金融业和保险业公司
- 筛选特定样本期间
- 剔除样本期间内被标记为 ST 和 ST* 的观测值
- 剔除上市时间不足 1 年的公司
在完成整体样本构建后,再将各类变量数据与整体样本进行合并(使用 merge 左连接),在合并后剔除连续缺失 3 年及以上的样本,对部分变量的随机缺失数据进行插值填补,最后再进行缩尾处理。这样做的好处是,可以避免在变量合并前就剔除样本,从而最大程度地保留样本量,提升数据利用效率。
下面给出一套基于 CSMAR 数据库 -> 上市公司基本信息 -> 上市公司基本信息年度表 的样本构建 Python 实现代码,必要字段包括:
["Symbol", "EndDate", "LISTINGDATE", "LISTINGSTATE", "CITYCODE", "PROVINCECODE"]由于 CSMAR 数据库中的上市公司基本信息年度表在筛选时可以选择剔除金融业和保险业公司,因此这里假设已经剔除金融业和保险业公司。此外,由于该表中包含了注册地在开曼地区等非中国地区的公司,因此也一并剔除这些样本。代码如下:
def filter_a_shares(df: pd.DataFrame) -> pd.DataFrame:
"""筛选沪深 A 股
Args:
df (pd.DataFrame): 原始 DataFrame,需包含 Symbol 列,即股票代码
Returns:
pd.DataFrame: 沪深 A 股样本 DataFrame
"""
df_copy = df.copy()
df_final = df_copy.loc[
df_copy.Symbol.str.startswith(("0", "3", "6")), :
].reset_index(drop=True)
return df_finaldef filter_by_period(df: pd.DataFrame, start_year: int, end_year: int) -> pd.DataFrame:
"""筛选特定样本期样本
Args:
df (pd.DataFrame): 原始 DataFrame,需包含 EndDate 和 Symbol 列,即观测日期和股票代码
start_year (int): 开始年份
end_year (int): 截止年份
Returns:
pd.DataFrame: 特定样本期样本
"""
df_copy = df.copy()
years = pd.to_datetime(df_copy.loc[:, "EndDate"]).dt.year
mask = (years >= start_year) & (years <= end_year)
df_final = df.loc[mask, :].reset_index(drop=True)
print(
f"{start_year}-{end_year} 样本期内,共有 {df_final.Symbol.nunique()} 家公司,{len(df_final)} 条样本"
)
return df_finaldef remove_ST_samples(df: pd.DataFrame) -> pd.DataFrame:
"""剔除在样本期间内被 ST/*ST/暂停上市 的观测值
Args:
df (pd.DataFrame): 原始 DataFrame,需包含 Symbol 和 LISTINGSTATE 列,即股票代码和上市状态。
Returns:
pd.DataFrame: 筛选后的 DataFrame
"""
df_copy = df.copy()
st_keywords = ("ST", "*ST", "暂停上市", "终止上市")
df_final = df_copy.query("LISTINGSTATE not in @st_keywords").reset_index(drop=True)
# 打印统计信息
print(
f"剔除样本期内被 ST 的观测值 -> 剔除后剩下 {df_final.Symbol.nunique()} 家公司,{len(df_final)} 条样本"
)
return df_finaldef filter_min_listing_age(
df: pd.DataFrame,
min_years: int = 1,
listing_date_col: str = "LISTINGDATE",
date_col: str = "EndDate",
) -> pd.DataFrame:
"""剔除上市时间不足一年的样本
Args:
df (pd.DataFrame): 原始 DataFrame,需包含 LISTINGDATE 和 EndDate 列,即上市日期和观测日期。
min_years (int, optional): 最低上市年限,默认为 1 年。
listing_date_col (str, optional): 上市日期列名,默认为 "LISTINGDATE"。
date_col (str, optional): 观测日期列名,默认为 "EndDate"。
Returns:
pd.DataFrame: 筛选后的 DataFrame
"""
df_copy = df.copy()
end_date = pd.to_datetime(df_copy.loc[:, date_col], errors="coerce")
listing_date = pd.to_datetime(df_copy.loc[:, listing_date_col], errors="coerce")
# 保留条件:上市日期 + 门槛年限 <= 观测日期
mask = (listing_date + pd.DateOffset(years=min_years)) <= end_date
df_final = df.loc[mask, :].reset_index(drop=True)
print(
f"剔除上市时间不足一年的样本 -> 剔除后剩下 {df_final.Symbol.nunique()} 家公司,{len(df_final)} 条样本"
)
return df_finaldef filter_by_city(df: pd.DataFrame) -> pd.DataFrame:
"""剔除注册地在开曼群岛等非中国地区的样本
Args:
df (pd.DataFrame): 原始 DataFrame,需包含 CITYCODE 列,即六位城市区划代码。
Returns:
pd.DataFrame: 筛选后的 DataFrame
"""
df_copy = df.copy()
df_final = df_copy.dropna(how="any", subset=["CITYCODE"]).reset_index(drop=True)
print(
f"剔除注册地在开曼群岛等非中国地区的样本 -> 剔除后剩下 {df_final.Symbol.nunique()} 家公司,{len(df_final)} 条样本"
)
return df_finalraw_data = pd.read_csv(
"./data/上市公司基本信息年度表/STK_LISTEDCOINFOANL.csv",
dtype={"Symbol": str, "EndDate": str, "CITYCODE": str, "PROVINCECODE": str},
)raw_data| Symbol | ShortName | EndDate | IndustryCodeD | LISTINGDATE | PROVINCECODE | CITYCODE | LISTINGSTATE | |
|---|---|---|---|---|---|---|---|---|
| 0 | 000002 | 深万科A | 2000-12-31 | K70 | 1991-01-29 | 440000 | 440300 | 正常上市 |
| 1 | 000002 | 深万科A | 2001-12-31 | K70 | 1991-01-29 | 440000 | 440300 | 正常上市 |
| 2 | 000002 | 万科A | 2002-12-31 | K70 | 1991-01-29 | 440000 | 440300 | 正常上市 |
| 3 | 000002 | 万科A | 2003-12-31 | K70 | 1991-01-29 | 440000 | 440300 | 正常上市 |
| 4 | 000002 | 万科A | 2004-12-31 | K70 | 1991-01-29 | 440000 | 440300 | 正常上市 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 62149 | 920992 | 中科美菱 | 2020-12-31 | C35 | 2022-10-18 | 340000 | 340100 | 正常上市 |
| 62150 | 920992 | 中科美菱 | 2021-12-31 | C35 | 2022-10-18 | 340000 | 340100 | 正常上市 |
| 62151 | 920992 | 中科美菱 | 2022-12-31 | C35 | 2022-10-18 | 340000 | 340100 | 正常上市 |
| 62152 | 920992 | 中科美菱 | 2023-12-31 | C35 | 2022-10-18 | 340000 | 340100 | 正常上市 |
| 62153 | 920992 | 中科美菱 | 2024-12-31 | C35 | 2022-10-18 | 340000 | 340100 | 正常上市 |
62154 rows × 8 columns
data = (
raw_data.pipe(filter_a_shares) # 筛选沪深 A 股
.pipe(filter_by_period, start_year=2015, end_year=2024) # 样本期设置为 2010-2024
.pipe(remove_ST_samples) # 剔除样本期间内被 ST 的观测值
.pipe(filter_min_listing_age) # 剔除上市时间不足一年的样本
.pipe(filter_by_city) # 剔除注册地在开曼群岛等非中国地区的样本
)2015-2024 样本期内,共有 4976 家公司,37639 条样本
剔除样本期内被 ST 的观测值 -> 剔除后剩下 4972 家公司,36816 条样本
剔除上市时间不足一年的样本 -> 剔除后剩下 4869 家公司,33789 条样本
剔除注册地在开曼群岛等非中国地区的样本 -> 剔除后剩下 4863 家公司,33764 条样本
data| Symbol | ShortName | EndDate | IndustryCodeD | LISTINGDATE | PROVINCECODE | CITYCODE | LISTINGSTATE | |
|---|---|---|---|---|---|---|---|---|
| 0 | 000002 | 万科A | 2015-12-31 | K70 | 1991-01-29 | 440000 | 440300 | 正常上市 |
| 1 | 000002 | 万科A | 2016-12-31 | K70 | 1991-01-29 | 440000 | 440300 | 正常上市 |
| 2 | 000002 | 万科A | 2017-12-31 | K70 | 1991-01-29 | 440000 | 440300 | 正常上市 |
| 3 | 000002 | 万科A | 2018-12-31 | K70 | 1991-01-29 | 440000 | 440300 | 正常上市 |
| 4 | 000002 | 万科A | 2019-12-31 | K70 | 1991-01-29 | 440000 | 440300 | 正常上市 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 33759 | 688800 | 瑞可达 | 2023-12-31 | C39 | 2021-07-22 | 320000 | 320500 | 正常上市 |
| 33760 | 688800 | 瑞可达 | 2024-12-31 | C39 | 2021-07-22 | 320000 | 320500 | 正常上市 |
| 33761 | 688819 | 天能股份 | 2022-12-31 | C38 | 2021-01-18 | 330000 | 330500 | 正常上市 |
| 33762 | 688819 | 天能股份 | 2023-12-31 | C38 | 2021-01-18 | 330000 | 330500 | 正常上市 |
| 33763 | 688819 | 天能股份 | 2024-12-31 | C38 | 2021-01-18 | 330000 | 330500 | 正常上市 |
33764 rows × 8 columns
参考文献
[1]丁宁,吴晓.存贷比监管改革与银行风险承担——来自中国商业银行的准自然实验[J].金融研究,2023,(02):96-114.
[2]黄先海,孙涌铭,陈梦涛.企业数字化转型与颠覆性技术创新——来自专利网络与SBERT模型的微观证据[J].中国工业经济,2024,(10):137-154.