简介:面向备战2021年安徽省大数据与人工智能应用竞赛人工智能(网络赛)本科组的选手及机器学习学习者,这份资料包含当年赛题的两部分核心数据:人脸图像和对应年龄标签,用于年龄估计任务;电梯、楼层、户型、区域、装修情况、面积、建筑时间等房源字段,用于预测房屋价格。数据按训练集、验证集、测试集约17000、3000、3000划分,部分字段有缺失,能较好检验数据清洗与特征处理功底。压缩包共1009个文件,以1000张JPG人脸图像和4个CSV数据表为主体,数据表对应训练、验证、测试等赛题数据,另附Python脚本与TXT说明辅助理解,整体仅13.47MB。当前已有1066人浏览学习。借助这些原始数据,学习者可跑通从数据读取、特征工程到模型训练与结果评估的完整流程,无论参赛备赛还是巩固机器学习实战能力,都有实际价值。
1. 这份赛题数据到底考什么:从「2021年安徽省大数据与人工智能应用竞赛人工智能(网络赛)-本科组赛题数据」说起
很多人搜到这份赛题数据,第一反应是「里面有标准答案」。实际上数据包里没有标准答案,只有带标签的训练样本、不带标签的待预测样本和提交模板;评分靠主办方后台脚本,不在数据包里。所以玩法很明确:在有限时间内从训练样本里学规律,在测试样本上做出尽量接近真实标签的预测,再按模板格式提交。
这份数据适合三类人:当年参赛想复盘的本科组选手、准备下一届比赛想熟悉真实题型的学生、拿它当教学案例或大数据课程设计素材的老师。一个带明确评分导向的赛题数据,本身就是完整的人工智能项目实战样本。后面所有步骤以「拿到手是一个压缩包」为前提展开,处理思路可平移到绝大多数同类竞赛。
2. 数据落地与全局体检:先把赛题数据变成能跑的训练集
拿到数据的第一个动作不是建模,而是把它变成一份「读得进、看得懂、跑得通」的 DataFrame。很多人在这一步图快,直接双击打开看一眼就开始了,等写到建模代码才发现列名带隐藏字符、行数和提交模板对不上,返工成本极高。我一般会先用十分钟做一次全局体检,把赛题数据的规模、字段类型、缺失情况和标签分布全部摸一遍,再决定后面的处理路线。
2.1 解压第一眼:先看文件组织,不要急着读 CSV
这类竞赛数据包的常见组织方式是:一个说明文档配若干数据文件。说明文档通常会写明字段含义、训练集与测试集的行数约定、提交格式和评分方式,它比网上任何二手介绍都可靠。先看一眼解压出来的文件列表:
unzip -o ai_competition_2021_data.zip -d ./data cd ./data ls -lh find . -maxdepth 2 -type f | sortunzip后面的参数-o表示覆盖解压,避免重复解压时提示交互;-d指定解压到./data目录。ls -lh看的是文件大小,find用来看完整路径和嵌套结构,有些包会把训练集、测试集、提交模板分放在不同子目录里,只靠肉眼扫文件名容易漏。
如果包里有 README 或题目说明文件,马上打开。我会先确认四件事:训练样本数量、测试样本数量、字段个数、评分指标。这四件事决定后面所有参数。比如测试集有单独文件,就要保证预处理时用同一套规则处理训练和测试;评分指标是准确率还是 F1,直接决定类别不均衡时要不要做重采样。多数竞赛为了避免参赛队伍手工标注测试集,测试样本一般不会太少,但也不会大到本地机器跑不动,徘徊在几千到几万行的量级是常态。
2.2 读取赛题数据的三件套:编码、分隔符、列名规范化
赛题数据文件最常见的格式是 CSV,但它在不同操作系统和导出工具手里长得很不一样。最典型的问题有三个:中文数据用了 GBK 编码、分隔符不是逗号而是\t、列名里带着前后空格或不可见字符。假设包里的主数据文件叫train.csv和test.csv,实际文件名以你解压出来的为准,下面的读取逻辑不用变:
import glob import pandas as pd files = glob.glob("./data/*.csv") print("找到的文件:", files) def read_csv_auto(path): for enc in ["utf-8", "gbk", "utf-8-sig", "latin1"]: try: df = pd.read_csv(path, encoding=enc, sep=None, engine="python") print(f"{path} 用 {enc} 读取成功, 形状: {df.shape}") return df except UnicodeDecodeError: continue except Exception as e: print(f"{path} 用 {enc} 读取失败:", e) raise ValueError(f"无法读取 {path}") train = read_csv_auto("./data/train.csv") test = read_csv_auto("./data/test.csv")sep=None配合engine="python"是让 pandas 自己猜分隔符,对\t、逗号、空格混合的情况比较稳,代价是速度慢一点。utf-8-sig专门处理带 BOM 的 Excel 导出文件,gbk处理 Windows 中文环境导出的文件,latin1是兜底方案,保证文件能被读进来而不是直接崩掉。
读取成功之后,先把列名里肉眼看不见的空格清理掉,统一成小写带下划线的风格。这一步省的是后面的命:手写特征公式时不用再纠结某个列到底叫User ID还是user_id。
train.columns = [str(c).strip().replace(" ", "_").lower() for c in train.columns] test.columns = [str(c).strip().replace(" ", "_").lower() for c in test.columns] print(train.head(3).T)strip()去掉首尾空格,replace(" ", "_")把列名里的空格替换成下划线,.lower()统一小写。列名规范化之后,字典访问和get_dummies展开都不容易打错键名。
2.3 标签与特征的全局体检:缺失率、分布、字段类型
数据读进来了,下一件事是弄清楚每列长什么样。我会同时做三个动作:看字段类型分布、看缺失率、看标签分布。这三个数字能过滤掉一大半后期故障。
def profile_df(df, name="dataset"): info = pd.DataFrame({ "dtype": df.dtypes.astype(str), "n_unique": df.nunique(), "missing_ratio": df.isna().mean().round(4), }) print(f"===== {name} 字段体检 =====") print(info.sort_values("missing_ratio", ascending=False)) return info train_info = profile_df(train, "train") if "label" in train.columns: print("标签分布:") print(train["label"].value_counts(normalize=True)) print("训练集样本数:", len(train)) print("测试集样本数:", len(test))df.isna().mean()直接给出每列的缺失比例,缺失率超过 0.5 的列要么扔掉,要么单独做「是否有值」的特征;nunique()对离散特征尤其重要,一个号称数值型的列如果只有三五个不同的取值,那它更可能是类别编码而不是连续值。标签分布用normalize=True看占比,如果某个类别占了九成以上,后续训练时要考虑class_weight,不然模型学出来的就是「全预测多数类」的复读机。
3. 建模前的特征工程与数据切分:把赛题数据从「能读」变「能学」
特征工程的价值在竞赛场景里被高估也被低估。高估的是那些炫技的组合特征,低估的是「先清理干净再做基础编码」这套基本功。网络赛时间紧,最稳妥的做法是先把缺失值、类别编码、字段筛选这三件事做扎实,让模型吃到的是一份语义干净、类型正确的输入。
3.1 缺失值处理要按列的类型分开处理
缺失值处理最大的误区是一刀切:要么全用均值填,要么全删行。实战里我按列的类型分三套规则:连续数值列用中位数填充,因为中位数对分布偏斜和离群点不敏感;低基数的类别列用众数填充,同时保留一个「该列原本缺失」的布尔特征;高基数的类别列不填充,直接把缺失当作一种类别值参与编码。这样做的理由是模型不止需要「缺失变成什么值」,更需要「这里缺过」这个信号本身。
import pandas as pd def fill_missing_by_type(df, ignore_cols=("label", "id")): df = df.copy() for col in df.columns: if col in ignore_cols or df[col].isna().sum() == 0: continue if pd.api.types.is_numeric_dtype(df[col]): # 数值列:中位数填充,并记录缺失标记 df[col + "_is_missing"] = df[col].isna().astype(int) df[col] = df[col].fillna(df[col].median()) else: # 类别列:填充"缺失"标记,不强行猜众数 df[col] = df[col].fillna("__MISSING__") return df train = fill_missing_by_type(train) test = fill_missing_by_type(test)pd.api.types.is_numeric_dtype比np.issubdtype更省事,能覆盖 int、float、bool 这些常见数值类型。数值列生成_is_missing标记列,是因为缺失本身有时和标签相关——比如用户没填收入,往往代表收入不稳定,这类模式模型单独从数值里学不出来。类别列填__MISSING__而不是众数,是为了避免把「缺失」这种特殊状态强行归入某一个常见类别,让编码阶段自己决定怎么对待它。
3.2 类别特征编码的次序:不能一上来就 LabelEncoder
很多人拿到赛题数据里的字符串列,第一反应就是LabelEncoder把它变成 0、1、2。这个操作对树模型勉强能用,对线性模型就是灾难,等于给类别强加了本不存在的顺序关系。我的默认顺序是这样:基数低(不超过 20 个取值)的类别列用 one-hot,基数高(几百上千个取值)的用计数编码,只有标签列才允许用 LabelEncoder。
# 低基数类别:one-hot low_card_cols = [c for c in train.columns if train[c].dtype == object and train[c].nunique() <= 20] train = pd.get_dummies(train, columns=low_card_cols) test = pd.get_dummies(test, columns=low_card_cols) # 高基数类别:计数编码(出现次数本身作为特征) for col in ["high_card_col_1", "high_card_col_2"]: # 换成实际的高基数列名 if col not in train.columns: continue train[col + "_count"] = train.groupby(col)[col].transform("count") test[col + "_count"] = test.groupby(col)[col].transform("count")pd.get_dummies的columns参数只对指定列做 one-hot,不会把数值列一起展开。高基数列用groupby(col)[col].transform("count")得到每个取值在全体样本里出现的次数,这个数字对树模型是有区分度的。注意训练和测试要各自算 count,不要混在一起算,否则测试集的信息会渗进训练特征,这就是一种很隐蔽的数据泄露。
提示:one-hot 展开后,训练集和测试集的列数很可能不一样。遇到这种情况,用
test = test.reindex(columns=train.columns, fill_value=0)对齐,能避免「测试集少一列」这种低级报错。
3.3 时间型与文本型字段怎么处理
网络赛的赛题数据里经常混着一两个看起来是字符串、实际是时间戳的列,以及一两个自由文本列。时间戳列的常见处理是把「可读性」翻译成「规律性」:拆出年、月、日、星期、小时,必要时算它与数据里最早日期的间隔天数。文本列则要看长度,如果平均每行只有几个词,做成 TF-IDF 特征就能给模型增量;如果是一大段话,说明赛题本身偏 NLP,后面按文本处理单独走。最怕的是把时间戳直接当字符串扔进类别编码,白白丢掉时序信息。
def parse_time_col(df, col): if col not in df.columns: return df s = pd.to_datetime(df[col], errors="coerce") df[col + "_year"] = s.dt.year df[col + "_month"] = s.dt.month df[col + "_day"] = s.dt.day df[col + "_wday"] = s.dt.dayofweek df[col + "_hour"] = s.dt.hour return df.drop(columns=[col]) train = parse_time_col(train, "create_time") test = parse_time_col(test, "create_time")pd.to_datetime加errors="coerce"可以把解析失败的字符串置为 NaT,避免整列报错;dt.year、dt.month这些是 pandas 的 datetime 访问器。如果时间列里混着「2021-09-01 08:30」和「2021/09/01」两种格式,不要自己写正则去切,直接交给to_datetime兜底。最后drop掉原始列,因为原始字符串对模型通常是噪音。
3.4 训练集与验证集切分:不要直接在原来的 train 上跑全量
特征工程做完,很多人直接拿整个训练集去训模型,然后用测试集提交。这中间的评估是缺失的:你不知道这个模型在没见过的样本上到底行不行。正确做法是先切出一部分做验证集,并且切分方式要跟赛题的真实评分逻辑对齐。如果数据没有时间属性,用带分层抽样的随机切分;如果有时间戳,按时间切分,训练集用前 80% 的时间段,验证集用后 20%。
from sklearn.model_selection import train_test_split X = train.drop(columns=["label"]) y = train["label"] X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) print("训练子集:", X_train.shape, "验证子集:", X_val.shape)stratify=y保证训练子集和验证子集里的标签比例和全集一致,这一步在类别不均衡时是必需的,否则验证集可能一个少数类都没有。random_state=42固定随机种子,让每次重跑得到同样的切分,不要留空,不然你调特征时看到的分数变化可能只是换了个切分的运气。
验证集切好后,训练和验证两路走同一个预处理管道,测试集则只在最后预测时过一遍。这里最容易翻车的是「预处理在切分之后做」:先切分再填充、再编码,两边的统计量会不一致,特征分布全乱。正确顺序是先合并处理规则、再切分。
4. 网络赛典型任务的模型选型与快速基线:从 LR 到树模型再到深度模型
特征处理完之后,进模型就快多了。竞赛环境的评分只认提交结果,不认模型复杂度,所以选型的第一原则不是先进,而是「在最短时间内跑出一个可信的分数作为参照」。我一般按三档递进:先用线性模型或随机森林建基线,再用 GBDT 系刷分,最后根据数据类型决定要不要上深度学习。
4.1 先跑一个「不调参也能交差」的基线
网络赛通常给三到五个小时,第一个小时内必须有一个能提交的结果。这时候最合适的是逻辑回归或者随机森林:逻辑回归在标准化后的特征上跑得很快,随机森林不用归一化也能用,二者都能在十分钟内给出一个不算丢人的分数。这个基线分数不是用来吹的,是用来垫底的——后面所有复杂方案都要拿它当标尺。
from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import accuracy_score, f1_score model_rf = RandomForestClassifier( n_estimators=200, max_depth=None, min_samples_leaf=2, n_jobs=-1, random_state=42, ) model_rf.fit(X_train, y_train) y_pred = model_rf.predict(X_val) print("验证集准确率: {:.4f}".format(accuracy_score(y_val, y_pred))) print("验证集 Macro-F1: {:.4f}".format(f1_score(y_val, y_pred, average="macro")))n_estimators=200是速度和精度的中间值,太多收益递减还拖时间;min_samples_leaf=2让叶子节点至少有两个样本,能压一压过拟合;n_jobs=-1用满所有核。准确率和 Macro-F1 都打印,因为很多竞赛用的评分指标不是准确率,而是更适合类别不均衡的 F1。把这两个数字记下来,它就是整个项目的「地板分」。
4.2 GBDT 系是这类竞赛的默认主力
如果基线跑完还有时间,下一步我会直接换 LightGBM。原因不是 XGBoost 不好,而是同样的时间预算下,LightGBM 能多试三组参数。这类赛题数据通常是大几百列几千行的规模,LightGBM 训练快、内存省、对类别特征和缺失值有原生处理,是限时赛里性价比最高的刷分工具。
from lightgbm import LGBMClassifier, early_stopping, log_evaluation model_lgb = LGBMClassifier( n_estimators=300, learning_rate=0.05, num_leaves=31, max_depth=-1, subsample=0.8, colsample_bytree=0.8, reg_alpha=0.1, reg_lambda=1.0, random_state=42, verbose=-1, ) model_lgb.fit( X_train, y_train, eval_set=[(X_val, y_val)], eval_metric="logloss", callbacks=[early_stopping(50), log_evaluation(50)], )learning_rate=0.05配合n_estimators=300,靠早停决定真正需要的树数量,比默认的 0.1 配 100 棵更稳。num_leaves=31是 LightGBM 最常见的初始值,超过 64 很容易在中小数据集上过拟合。subsample和colsample_bytree都是 0.8,相当于给模型加了一点随机性。reg_alpha和reg_lambda是 L1 和 L2 正则,初始给一个小值即可。
这里有个容易踩的细节:eval_metric要跟赛题评分指标尽量一致。如果评分是 F1,就别只用 logloss 判断早停,否则模型在验证集上可能停错位置。时间充裕的话,把eval_metric换成赛题指标重跑一遍,对比两次早停的验证分数。
4.3 深度学习什么时候才值得上
很多新手一看到「人工智能竞赛」就默认要上神经网络,其实这是误解。赛题数据如果是结构化表格,GBDT 通常是更好的选择,深度模型在几千行表格数据上很难打过调好的树模型,还容易在限时赛里因为训练和推理时间翻车。真正该上深度学习的场景只有两种:字段里有大量文本且 TF-IDF 特征不够用,或者数据本身就是图像、语音这类非结构化数据。
文本场景的快速做法是分词后直接用 TF-IDF 加一个线性分类器,先拿这个当文本基线;如果分数不满意,再考虑用预训练模型微调。预训练模型的关键参数是max_length和batch_size,分别控制单条样本长度和每批样本数,这两个值要配合显卡显存来调。图像场景则用现成的预训练 CNN 抽取特征后接分类头,尽量不要从头训练。网络赛的时间约束决定了:深度学习是「最后冲刺的手段」,不是「第一步的选择」。
4.4 预测结果的落盘与提交格式
模型选完、验证集分数满意,最后一步是把测试集的预测结果写出来。这一步的报错率远超想象,最典型的问题是行数不对、索引错位、列名不符合模板要求。我的习惯是永远以提交模板为准,先读模板看它要求几列、列名叫什么,再按同样的格式输出。
submit = pd.read_csv("./data/sample_submit.csv") pred_test = model_lgb.predict(test) out = pd.DataFrame({ "id": test["id"], "label": pred_test, }) out.to_csv("./submit_lgb.csv", index=False, encoding="utf-8") print("提交文件形状:", out.shape, "模板形状:", submit.shape)不加index=False是很多人翻车的重灾区,pandas 默认会把行号写成第一列,评分脚本一读,列数对不上直接报错。输出前打印shape和模板对比一次,确认行数一致再交。如果模板里的 id 顺序和测试集文件里的顺序不一样,务必用模板的 id 顺序重排,不要用模型预测时的原始顺序直接交。
5. 赛题数据的常见坑与现场排查:现象、原因、解决的五个记录
这一章写的是我在这类竞赛数据上真实踩过的坑。每个都按现象、原因、解决三步展开。如果你在复现时遇到类似报错,直接对着查。
5.1 中文列名和中文内容乱码
现象:read_csv读出来的列名是一串乱码,或者数据内容里的中文全部变成问号,提交时模型训练和预测表现正常但分数极低。
原因:文件是 Excel 导出或者 Windows 记事本另存的 CSV,编码是 GBK,或者带 BOM 的 UTF-8,而pd.read_csv默认用 UTF-8 解码。
解决:优先用encoding="utf-8-sig"和encoding="gbk"依次尝试,这俩覆盖了国内竞赛数据九成以上的编码情况。实在不行再用latin1兜底,但latin1读进来的中文串在后续特征处理时很可能不是你想要的内容,只能保证程序不崩。
5.2 训练集和测试集的类别取值不一致
现象:验证集分数很高,提交后线上分数大幅下降;再检查发现某些类别特征在测试集里出现了训练集没有见过的取值。
原因:训练集和测试集是分别从整体数据里抽样出来的,某个低频类别恰好只出现在测试集里。建模阶段如果在全量训练集上做LabelEncoder,或在pd.get_dummies后直接训练,模型没见过测试集的类别组合,预测时就只能靠默认路径糊弄。
解决:所有类别编码器都只用训练集去fit,测试集走同一个transform;用pd.get_dummies时用columns限定要展开的列,之后用reindex补齐训练集和测试集的列差。真实竞赛数据里这类「分布漂移」很常见,提前在验证阶段就把它模拟出来,比上线后补救值钱得多。
5.3 本地分数高但线上分数低
现象:本地验证集 Macro-F1 到了 0.85,提交线上只有 0.72,来回调参都拉不回来。
原因:绝大多数是本地验证切分和线上评分逻辑不一致。如果赛题按时间顺序划分训练集和测试集,本地却用了随机切分,那模型在本地看到的验证样本分布基本和训练集同源,线上测试则是另一个时间段的数据,分布天然有偏移。
解决:先翻题目的说明文档,确认评分集是按时间切还是随机切。按时间切就用TimeSeriesSplit,或手动按时间字段前 80% 训练、后 20% 验证,并把这种切分固化在代码里,不要每次跑都重新随机。按随机切就用五折交叉验证的平均分替代单次验证分,单次验证的运气成分太大。
5.4 提交格式和评分脚本要求不一致
现象:提交后系统提示「文件行数不符」或「字段缺失」,连分都没有。
原因:输出 CSV 时写了index=True,或者测试集在特征工程里被dropna删了行,导致输出行数和模板不一致;还有可能是模板里 id 列名是ID,你写的是id。
解决:在to_csv时固定写index=False,特征工程阶段不要对整个 DataFrame 做无脑dropna。输出前先做一次形状和列名校验,并且用模板的 id 顺序兜底重排,永远不要迷信「我眼里看起来一样」。
5.5 数据泄露:看起来有用的列其实是「后悔药」
现象:某列特征和标签的关联高到离谱,比如训练集里某个数值列只要大于某个阈值标签必为 1,模型分数奇高,提交后却惨不忍睹。
原因:赛题数据里可能包含类似身份 ID、报名序号这类列,它们不携带语义,但恰好和样本的划分顺序、标签生成顺序有相关性,模型把「排序规律」当成了「业务规律」来学。
解决:体检阶段就先删掉 id、序号、行号这类纯标识列。如果非要用,做一个严谨的验证:把该列打乱后重新训练,分数变化超过一定幅度就说明模型在靠它走捷径。走捷径的模型一上线就会在真实分布里现原形,这是名副其实的「后悔药」,吃下去一时爽,事后全是债。
6. 用这份赛题数据做沉淀:一套可复用的验证套路与交付整理
竞赛结束不等于数据作废。我会把整套处理流程按「数据体检 → 特征工程 → 基线模型 → 调参实验 → 提交记录」五个脚本归档,每个脚本留下当时的运行结果日志。这样同一个赛题数据,一周后重跑能在十分钟内恢复全部上下文,下一届比赛换个数据包也能直接复用脚本框架。归档目录长这样:
project/ ├── 0_data_profile.py ├── 1_feature_engineering.py ├── 2_baseline.py ├── 3_best_model.py ├── 4_make_submit.py └── logs/ ├── data_profile.txt └── experiment_log.csv日志里只记三样东西:数据集版本、验证策略、最终分数。调参是玄学,但玄学也要有记录,否则你永远不知道上一个高分是哪个参数组合跑出来的。我自己的习惯是每个实验一行,列名和参数写全,跑完模型就追加一行,哪怕当时的分数再低也留着,低分记录往往比高分更能说明问题。
最后补一个被很多人忽略的验证细节:提交一次之后,把线上分数回填到本地实验日志里,用线上分数校准本地的验证策略。如果线上总是比本地低两三个点,说明本地验证偏乐观,下次切分要把验证集的难度调高一点。这个「线上-本地分差」跟踪几次之后,你的验证策略会越来越贴近真实赛场,比我见过的大多数调参攻略都来得实在。
我现在不管拿到的是哪年哪省的赛题数据,第一件事永远是先写好0_data_profile.py,让数据先开口说话,再谈模型。希望帮到你。
本文还有配套的精品资源,点击获取