ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于测井曲线与机器学习的储层岩性识别实战指南

基于测井曲线与机器学习的储层岩性识别实战指南 简介这是一套面向高校学生与初学者的地下储层岩性识别机器学习项目源码围绕石油勘探中岩性分类这一实际问题提供从数据到模型训练的完整实现路径适合用作毕业设计、课程项目或期末考核的参考方案。压缩包共259个文件约169.38MB以csv数据集为主体辅以xlsx表格、zbak备份、ipynb笔记本、py脚本及docx技术文档涵盖训练数据、未归一化原始数据与预处理完成数据等多类文件便于对照理解数据预处理与建模流程。目前已有86人学习下载。资源包含可运行代码、详细注释与配套文档读者可据此快速搭建识别系统掌握特征提取、模型训练与验证的完整思路并借助多地区、多地质条件的数据集加深对算法泛化能力的理解是入门机器学习与地质数据分析的实用起点。1. 地下储层岩性识别从测井曲线到机器学习模型一条能落地的技术路线地下储层岩性识别这件事说白了就是回答一个核心问题钻头底下这几千米的岩石到底是砂岩、泥岩、碳酸盐岩还是煤。传统做法靠地质家盯着测井曲线人工解释一条井解释下来少则几小时多则几天而且不同人解释同一段曲线经常打架。我见过最离谱的一次同一口井同一段三个工程师给出三种岩性结论最后靠取心才定案。机器学习切入这个场景的价值就在于把测井曲线当成特征向量把取心或录井结果当成标签训练一个分类模型让机器学会“看曲线认岩石”。这套系统适合谁适合有测井数据但人工解释产能跟不上的油田研究院、录井公司技术团队以及做地质建模需要大量岩性标签的数据工程师。它不玄学核心就是特征工程加分类器调参但坑集中在数据对齐和类别不平衡上后面会逐条拆。2. 数据准备与特征工程把测井曲线变成模型能吃的矩阵2.1 测井曲线选哪几条GR、RT、DEN、AC、CNL 的取舍逻辑测井曲线有几十条但不是全塞进去就好。我一般先看三条自然伽马 GR 区分泥岩和砂岩电阻率 RT 区分流体和致密层密度 DEN 和中子 CNL 组合判断岩性密度。声波 AC 对孔隙度敏感碳酸盐岩和砂岩的 AC 响应差异明显。煤层的典型特征是 GR 低、DEN 低、AC 高这三条组合就能把煤单独拎出来。选曲线时有个硬约束深度对齐。不同测井仪器的采样间隔不一样GR 可能 0.125 米一个点DEN 可能 0.15 米一个点。直接按行拼接会错位必须按深度重采样到统一网格。我一般用 0.125 米作为基准线性插值补齐缺失深度点。import pandas as pd import numpy as np # 假设已有各曲线 DataFrame列名为 depth 和曲线名 def align_logs(log_dict, base_step0.125): log_dict: {GR: df_gr, RT: df_rt, ...} 返回按统一深度网格对齐后的 DataFrame # 确定深度范围 all_depths np.concatenate([df[depth].values for df in log_dict.values()]) d_min, d_max all_depths.min(), all_depths.max() base_depth np.arange(d_min, d_max, base_step) aligned pd.DataFrame({depth: base_depth}) for name, df in log_dict.items(): # 按深度排序后插值 df_sorted df.sort_values(depth) aligned[name] np.interp(base_depth, df_sorted[depth], df_sorted[name]) return aligned这段代码的逻辑是先取所有曲线深度的并集范围生成等间距深度网格再对每条曲线做线性插值。参数base_step控制采样精度0.125 米是常见测井采样间隔如果原始数据更密可以调到 0.05。插值后要检查 NaN 比例超过 30% 的深度段建议直接丢弃不要硬填。2.2 标签从哪来取心、录井、解释结论的优先级与对齐标签质量决定模型天花板。取心数据最可靠但最少一口井可能只有几十米取心段。录井岩屑描述覆盖全井段但深度有滞后通常滞后 2 到 5 米。人工解释结论覆盖最全但主观性强。我的做法是取心段作为金标准录井数据做补充人工解释只用来做交叉验证。对齐标签和曲线时取心深度要按取心收获率校正。比如取心段 2000 到 2005 米收获率 95%实际岩心对应深度要按比例压缩。录井标签要做深度平移一般把录井描述深度减去 3 米再和曲线匹配。这些校正不做模型学到的就是错位关系准确率虚高但实际用不了。def build_labeled_dataset(aligned_logs, core_labels, mudlog_labels, shift3.0): aligned_logs: 对齐后的曲线 DataFrame core_labels: DataFrame列 depth, lithology mudlog_labels: DataFrame列 depth, lithology shift: 录井深度平移量单位米 # 取心标签优先 df aligned_logs.copy() df[lithology] np.nan # 先匹配取心 for _, row in core_labels.iterrows(): mask (df[depth] row[depth] - 0.0625) (df[depth] row[depth] 0.0625) df.loc[mask, lithology] row[lithology] # 取心未覆盖的用录井补充 mudlog_shifted mudlog_labels.copy() mudlog_shifted[depth] mudlog_shifted[depth] - shift for _, row in mudlog_shifted.iterrows(): mask (df[depth] row[depth] - 0.0625) (df[depth] row[depth] 0.0625) if df.loc[mask, lithology].isna().all(): df.loc[mask, lithology] row[lithology] # 丢弃无标签深度点 df df.dropna(subset[lithology]) return df逻辑说明先按取心标签打标取心没覆盖的深度再用平移后的录井标签补。参数shift是录井深度滞后量不同油田不一样我见过 2 米也见过 5 米最好用取心段做标定。最后丢弃无标签点保证训练集干净。2.3 特征衍生比值、差值、滑动窗口统计量原始曲线直接喂模型也能跑但加一些衍生特征效果提升明显。我常加三类比值特征如 GR/DEN 区分砂岩和碳酸盐岩差值特征如 DEN-CNL 识别气层滑动窗口统计量如 5 点均值和方差平滑噪声。注意窗口大小要跟地层厚度匹配薄层用 3 点厚层用 7 点。def add_features(df, window5): df df.copy() # 比值特征 df[GR_DEN_ratio] df[GR] / (df[DEN] 1e-6) # 差值特征 df[DEN_CNL_diff] df[DEN] - df[CNL] # 滑动窗口统计 for col in [GR, RT, DEN, AC]: df[f{col}_mean_{window}] df[col].rolling(window, centerTrue).mean() df[f{col}_std_{window}] df[col].rolling(window, centerTrue).std() df df.dropna() return df参数window控制平滑程度5 是常用值。注意centerTrue保证窗口居中避免引入未来深度信息。衍生特征做完后建议做相关性筛查相关系数超过 0.95 的特征留一个就行多了反而增加过拟合风险。3. 模型选型与训练随机森林、XGBoost 和 SVM 在岩性识别上的实测对比3.1 为什么我首选随机森林做基线岩性识别是典型的多分类问题类别包括砂岩、泥岩、碳酸盐岩、煤等类别不平衡严重。随机森林对不平衡数据有一定容忍度特征重要性可解释调参少训练快。我一般先用随机森林跑一个基线看混淆矩阵里哪类岩性容易被混。常见的是砂岩和碳酸盐岩混因为 GR 响应有重叠。这时候再针对性加特征或换模型。from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import StratifiedKFold, cross_val_score from sklearn.metrics import classification_report # X 是特征矩阵y 是岩性标签 rf RandomForestClassifier( n_estimators300, max_depth15, min_samples_leaf5, class_weightbalanced, random_state42, n_jobs-1 ) # 分层交叉验证 cv StratifiedKFold(n_splits5, shuffleTrue, random_state42) scores cross_val_score(rf, X, y, cvcv, scoringf1_macro) print(fF1 macro: {scores.mean():.3f} ± {scores.std():.3f})参数说明n_estimators300是树的数量再多边际收益低max_depth15控制树深防止过拟合min_samples_leaf5保证叶子节点最少样本数对不平衡数据重要class_weightbalanced自动按类别频率加权。评估用f1_macro而不是准确率因为准确率在类别不平衡时会虚高。3.2 XGBoost 调参学习率、树深、正则项的实操取值XGBoost 在岩性识别上通常比随机森林高 2 到 5 个百分点但调参更敏感。我的经验取值学习率 0.05 到 0.1树深 6 到 10子采样 0.8列采样 0.8正则项 lambda 1 到 5。学习率低于 0.05 需要更多轮次训练时间翻倍但提升有限。import xgboost as xgb from sklearn.preprocessing import LabelEncoder le LabelEncoder() y_encoded le.fit_transform(y) xgb_model xgb.XGBClassifier( n_estimators500, learning_rate0.08, max_depth8, subsample0.8, colsample_bytree0.8, reg_lambda2.0, reg_alpha0.5, objectivemulti:softmax, num_classlen(le.classes_), random_state42, n_jobs-1 ) xgb_model.fit(X_train, y_train_encoded) y_pred xgb_model.predict(X_test) print(classification_report(y_test_encoded, y_pred, target_namesle.classes_))关键参数learning_rate0.08是步长太大震荡太小慢max_depth8比随机森林浅因为 XGBoost 每棵树拟合残差深了容易过拟合reg_lambda2.0和reg_alpha0.5是 L2 和 L1 正则岩性数据噪声大时调大正则能稳住。注意 XGBoost 要求标签是 0 到 n-1 的整数所以先用 LabelEncoder 转。3.3 SVM 在小样本井上的表现与核函数选择有些老井取心数据只有几百个点这种小样本场景 SVM 反而稳。核函数选 RBFgamma 设 0.1 到 1C 设 1 到 10。SVM 对特征尺度敏感必须先标准化。我一般用 StandardScaler 把每条曲线缩到均值 0 方差 1。from sklearn.svm import SVC from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline svm_pipe Pipeline([ (scaler, StandardScaler()), (svm, SVC( kernelrbf, C5.0, gamma0.5, class_weightbalanced, probabilityTrue, random_state42 )) ]) svm_pipe.fit(X_train, y_train) y_pred_svm svm_pipe.predict(X_test)参数说明C5.0控制惩罚力度越大越容易过拟合gamma0.5控制 RBF 核宽度越大越容易过拟合。小样本时 C 和 gamma 都要调小。probabilityTrue开启概率输出方便后续做不确定性分析但会慢一些。3.4 类别不平衡处理过采样、欠采样与代价敏感学习的取舍岩性数据里泥岩可能占 60%煤可能只占 2%。直接训练模型会偏向多数类。我试过三种方案SMOTE 过采样、随机欠采样、代价敏感学习。SMOTE 在岩性数据上容易生成不存在的曲线组合我一般不用。欠采样会丢信息只在多数类样本极多时用。最稳的是代价敏感学习即class_weightbalanced或 XGBoost 的scale_pos_weight。from imblearn.combine import SMOTETomek # 如果一定要用采样SMOTETomek 比纯 SMOTE 稳 smt SMOTETomek(random_state42) X_resampled, y_resampled smt.fit_resample(X_train, y_train) print(f原始分布: {np.bincount(y_train_encoded)}) print(f采样后分布: {np.bincount(le.transform(y_resampled))})注意采样只在训练集做测试集保持原始分布。采样后要重新做交叉验证不能再用原始 CV 划分。我的习惯是先用代价敏感学习跑一版如果少数类 F1 还是低于 0.5再考虑采样。4. 系统开发与部署从 Jupyter Notebook 到可交互的岩性识别工具4.1 后端推理服务Flask 还是 FastAPI模型训练完要给别人用不能让人家跑 Notebook。我一般用 FastAPI 包一个推理接口因为异步支持好自动生成文档。接口设计很简单POST 传深度和曲线值返回岩性预测和概率。from fastapi import FastAPI from pydantic import BaseModel import numpy as np import joblib app FastAPI() model joblib.load(xgb_lithology_model.pkl) le joblib.load(label_encoder.pkl) class LogInput(BaseModel): depth: float GR: float RT: float DEN: float AC: float CNL: float app.post(/predict) def predict_lithology(data: LogInput): features np.array([[ data.GR, data.RT, data.DEN, data.AC, data.CNL, data.GR / (data.DEN 1e-6), data.DEN - data.CNL ]]) pred model.predict(features)[0] proba model.predict_proba(features)[0] return { lithology: le.inverse_transform([pred])[0], probability: float(proba.max()), all_probabilities: dict(zip(le.classes_, proba.tolist())) }逻辑说明接口接收单点曲线值内部做同样的特征衍生再调模型预测。参数probability返回最大概率低于 0.6 的建议人工复核。all_probabilities返回所有岩性概率方便前端画置信度条。4.2 前端交互用 Streamlit 快速搭一个曲线预测展示页前端不用写 HTMLStreamlit 几十行就能搭一个。上传 CSV 或手动输入曲线值实时显示预测结果和概率分布。import streamlit as st import pandas as pd import requests st.title(地下储层岩性识别) uploaded st.file_uploader(上传测井曲线 CSV, typecsv) if uploaded: df pd.read_csv(uploaded) st.line_chart(df[[GR, DEN, AC]]) if st.button(逐点预测): results [] for _, row in df.iterrows(): resp requests.post(http://localhost:8000/predict, jsonrow.to_dict()) results.append(resp.json()[lithology]) df[预测岩性] results st.dataframe(df[[depth, 预测岩性]]) st.bar_chart(df[预测岩性].value_counts())这段代码逻辑上传 CSV 后先画曲线点预测按钮后逐行调后端接口结果拼回 DataFrame 展示。实际部署时建议批量接口逐点调太慢。Streamlit 适合内部工具不用考虑并发。4.3 模型持久化与版本管理joblib、ONNX 和 MLflow 的适用场景模型保存用 joblib 最简单但跨语言部署要转 ONNX。我一般训练时用 joblib 存 sklearn 和 XGBoost 模型部署时如果后端是 Python 就直接加载如果是 C 或 Java 就转 ONNX。版本管理用 MLflow 记录每次训练的参数字段和指标方便回溯。import joblib import mlflow import mlflow.xgboost with mlflow.start_run(): mlflow.log_params({ learning_rate: 0.08, max_depth: 8, n_estimators: 500 }) mlflow.log_metric(f1_macro, 0.87) mlflow.xgboost.log_model(xgb_model, model) joblib.dump(le, label_encoder.pkl)MLflow 的好处是每次实验自动记录不用手动记笔记。参数f1_macro是评估指标建议同时记录混淆矩阵。模型文件建议加时间戳避免覆盖旧版本。5. 避坑与排查岩性识别系统落地时最容易翻车的 5 个地方5.1 深度对齐没做模型学了个寂寞现象训练集准确率 95%一到新井就掉到 60%。原因不同曲线深度没对齐模型学到的是错位关系。解决按 2.1 节的方法统一重采样到 0.125 米网格对齐后随机抽 10 个深度点人工核对曲线值是否合理。5.2 标签泄漏用解释结论训练又用解释结论测试现象交叉验证 F1 0.95实际部署惨不忍睹。原因训练集和测试集来自同一段解释结论模型记住了这段的曲线形态。解决按井划分训练集和测试集同一口井的数据不能同时出现在两边。如果井数太少按深度段划分但段之间要留缓冲。5.3 类别不平衡没处理煤和碳酸盐岩全被预测成泥岩现象混淆矩阵里煤的召回率 0.1泥岩 0.98。原因泥岩样本占 70%模型直接全猜泥岩就能拿高准确率。解决用class_weightbalanced或 XGBoost 的scale_pos_weight评估看f1_macro不看准确率。如果少数类 F1 还是低再考虑 SMOTETomek。5.4 特征尺度没统一SVM 直接罢工现象SVM 训练不收敛或者预测结果全是同一类。原因GR 范围 0 到 150DEN 范围 2 到 3尺度差 50 倍RBF 核直接失效。解决用 StandardScaler 标准化所有特征缩到均值 0 方差 1。注意标准化参数只能在训练集拟合再应用到测试集。5.5 推理服务没做输入校验异常值把模型带偏现象接口收到 GR9999 的脏数据模型输出一个不存在的岩性概率 0.99。原因没做输入范围校验。解决在 FastAPI 的 Pydantic 模型里加Field(ge0, le200)约束超出范围的直接返回 400。同时模型推理前做一次异常检测比如 GR 超过 300 的深度点标记为可疑。6. 进阶技巧用 SHAP 解释模型决策并做不确定性筛选模型跑通只是第一步地质家信不信才是关键。我一般用 SHAP 值解释每个深度点的预测依据告诉地质家“模型认为这是砂岩因为 GR 低、DEN 中等、AC 偏高”。SHAP 图可以直接嵌到前端点某个深度就显示各曲线贡献度。import shap explainer shap.TreeExplainer(xgb_model) shap_values explainer.shap_values(X_test) # 对单个深度点解释 sample_idx 0 shap.force_plot( explainer.expected_value[0], shap_values[0][sample_idx], X_test.iloc[sample_idx], feature_namesX_test.columns.tolist() )参数说明TreeExplainer适合树模型速度快shap_values返回每个特征对每个类别的贡献。force_plot画单点解释summary_plot画全局特征重要性。注意 SHAP 计算量随特征数线性增长特征超过 50 个时建议先做特征筛选。另一个技巧是预测不确定性筛选。模型输出概率低于 0.6 的深度点自动标记为“需人工复核”并推送给地质家。这样既用了模型的速度又保留了人工兜底。我一般把阈值设在 0.6 到 0.7 之间太低复核量太大太高漏掉难样本。def flag_uncertain(proba, threshold0.65): proba: 模型输出的概率矩阵shape (n_samples, n_classes) 返回需要人工复核的索引 max_proba proba.max(axis1) uncertain_idx np.where(max_proba threshold)[0] return uncertain_idx这个函数逻辑简单但实用。threshold0.65是经验值可以根据复核人力调整。返回的索引对应深度点前端高亮显示。我自己的习惯是每周统计一次复核点的真实岩性如果模型在复核点上错得多说明阈值要调高或者模型要重新训练。最后说个血泪教训别指望一个模型打天下。不同油田、不同层系的测井响应差异很大我一般按区块分别训练模型每个模型只服务一个区块。跨区块迁移学习试过效果不稳定不如多训几个小模型省心。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进