简介:基于机器学习的MBTI人格预测系统项目,是一套面向高校课程设计、毕业设计及入门级AI开发者的完整工程资源。项目通过对个体语言与行为模式进行数据清洗和特征分析,构建用于MBTI人格类型预测的机器学习模型,并完成参数调优与效果评估;同时配套用户界面,实现从数据输入到预测结果的端到端集成,覆盖自然语言处理和分类建模的典型落地场景。资源包含2000个文件,主体为1894个Python源码,功能涵盖数据预处理、特征工程、模型训练与评估等模块;另有少量C/H文件用于底层计算加速,txt为数据或使用说明,docx/md为项目计划与可行性报告,压缩包共253.46MB。截至目前,已有1819人学习或下载这套资源,内容组织的结构化程度较高,适合按文档顺序逐模块上手实践。学习者可以从中梳理出完整的项目开发链路,包括数据探索、模型调参、性能评估和前后端集成的方法,并利用内附的开发计划和报告快速定位关键环节,作为立项或答辩材料参考。
1. 基于机器学习的 MBTI 人格预测系统:它到底在预测什么
做过招聘或社交产品推荐的人都知道,MBTI 十六型人格测试最尴尬的地方在于:问卷题全是自评,候选人完全可以按“岗位画像”把自己填成另一个类型。而真正能反映性格的信息,往往藏在日常表达里——你写一段自我介绍或一条社交动态,用词习惯、句子长短、标点密度、情绪词倾向,这些“非刻意数据”很难伪装。这个资源做的就是这件事:用机器学习,从一段自由文本预测 MBTI 四个维度(E/I 内外向、S/N 实感与直觉、T/F 思考与情感、J/P 判断与知觉),最后把模型封装成带网页界面的完整预测系统。适合手里有点 Python 基础、想完整走一遍“数据清洗 → 特征工程 → 模型调参 → 评估 → 界面集成”全流程的人,也适合课程设计或简历项目。这里要提前说清一个反直觉结论:不要一上来就训练 16 类分类器,四维度拆开做二分类,效果会好得多,后面的内容全部围绕这个思路展开。
2. 数据清洗与特征工程:把性格文本变成模型认得的输入
2.1 原始文本先按“人”去重,再按四个维度拆标签
人格预测的数据集通常长这样:每一行是一条文本记录,带一个像“INTJ”或“ENFP”这样的类型标签。拿到手第一件事不是分词,而是先搞清楚“同一个人发过好几条文本”这个事实。如果同一用户的多条文本被拆到训练集和测试集两侧,模型会“偷看”到同一个人不同文本的相似表达,测试指标虚高,上线就翻车。所以我一般会按用户 ID(或文本主题行的前缀)去重分组,保证同一个人的文本全部落在同一边。
import pandas as pd df = pd.read_csv("mbti_data.csv") # 按文本主题前缀提取用户ID:数据集里每行通常是"INTJ||今天开会又迟到…"这种格式 df["user_id"] = df["text"].str.split("||").str[0] df["raw_text"] = df["text"].str.split("||").str[1] # 去掉完全重复的行 df = df.drop_duplicates(subset=["user_id", "raw_text"]) # 标签拆成四个维度,每个维度都是二分类 df["EI"] = df["user_id"].apply(lambda t: 0 if t[0] == "E" else 1) df["SN"] = df["user_id"].apply(lambda t: 0 if t[1] == "S" else 1) df["TF"] = df["user_id"].apply(lambda t: 0 if t[2] == "T" else 1) df["JP"] = df["user_id"].apply(lambda t: 0 if t[3] == "J" else 1) # 按用户分组切分,而不是按行随机切分 users = df["user_id"].unique() train_users = users[: int(len(users) * 0.8)] train_df = df[df["user_id"].isin(train_users)].copy() test_df = df[~df["user_id"].isin(train_users)].copy()这段代码做了两件关键事:先用“||”分隔符把类型标签和正文拆开,再把 16 类标签拆成 EI/SN/TF/JP 四个 0/1 标签。切分数据时用的是用户 ID 列表的前 80%,不是按行 shuffle,这是人格文本数据最容易出数据泄漏的地方。如果你的数据集没有明显的用户 ID,但存在多条文本来自同一账号,也建议用账号字段分组再做 StratifiedSplit。
2.2 文本特征:TF-IDF 打底,手工特征补上“说话习惯”
模型要认的不是“词义”,而是“表达习惯”。TF-IDF 能抓住关键词(比如“我觉得”“可能”“应该”这类词在不同类型里的使用频率差异很大),但它完全忽略句长、标点、情绪词密度这些强信号。我实测下来,内向型(I)文本平均句长更长、第一人称代词更多;直觉型(N)文本抽象词更多、数字更少。因此特征要做成两部分的拼接。
from sklearn.feature_extraction.text import TfidfVectorizer import re def build_manual_features(texts): feats = [] for t in texts: sentences = re.split(r"[.!?。!?]", t) feats.append([ len(t), # 总字符数 len(t.split()) / max(len(sentences), 1), # 平均句长 t.count("!") / max(len(t), 1), # 感叹号密度 t.count("?") / max(len(t), 1), # 问号密度 len(re.findall(r"\bI\b|\bim\b|\bmy\b", t, re.I)) / max(len(t.split()), 1), # 第一人称密度 len(re.findall(r"\d", t)) / max(len(t), 1) # 数字密度 ]) return feats tfidf = TfidfVectorizer(ngram_range=(1, 2), max_features=5000, min_df=3) X_tfidf = tfidf.fit_transform(train_df["raw_text"]) X_manual = build_manual_features(train_df["raw_text"]) # 手工特征做标准化,防止量纲影响 from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_manual_scaled = scaler.fit_transform((X_manual)) # 拼接成最终特征矩阵 from scipy.sparse import hstack, csr_matrix X_train = hstack([X_tfidf, csr_matrix(X_manual_scaled)]).tocsr()这里的参数值得说细一点:ngram_range=(1, 2)让模型能看到“not bad”这种二元搭配,对情感判断有帮助;max_features=5000是经验值,太大容易让矩阵稀疏到模型学不动,太小又丢失关键词;min_df=3表示出现次数少于 3 次的词直接丢弃,避免长尾噪音词进入特征。手工特征里最容易出效果的是感叹号密度——公开数据里 ENFP 和 ESFP 这类外向型文本的感叹号密度明显高于平均,这个特征帮了我大忙。拼接完的特征矩阵是稀疏矩阵,后面喂给线性模型和树模型都兼容。
2.3 标签均衡性检查:先看分布再定模型策略
四个维度里最容易遇到的是 E/I 相对均衡,而 J/P 或 T/F 严重不均衡。我见过 T/F 维度 T 占 70%、F 占 30% 的情况,这时候如果直接训练,模型会倾向于全部预测为 T,拿到的准确率虚高。所以在训练前必须打印每个维度的正负样本比例,后面好决定是否加class_weight。
for col in ["EI", "SN", "TF", "JP"]: print(col, train_df[col].value_counts(normalize=True))看到比例后记住一个经验规则:样本比例低于 35% 的类别就算失衡,后面建模时要专门处理,不要指望模型自己学平。这一步不需要写太多代码,但决策价值极高。很多人直接跳过它,跑完评估发现某个维度 F1 只有 0.4,再回头查分布,白白浪费一天时间。
3. 模型选型与参数调优:四个二分类模型比一个 16 类分类器更靠谱
3.1 为什么拆成四个二分类:类别数据量撑不起 16 类
16 类里有些类型(比如 INFJ 和 INTJ 的文本风格差异本身就很微妙)样本可能只有几十条,树模型一深就过拟合,线性模型权重学不准。把任务拆成四个二分类后,每个模型只回答一个“是/否”问题,样本量翻了 8 倍(原来是 1/16 的数据量,现在每个维度有 1/2 的数据量参与训练),类别平衡问题也简化了。这是这个系统能保持稳定预测的核心决策。
为了得到公平的基线,我一般会先跑三个模型做对比:逻辑回归、朴素贝叶斯、支持向量机。逻辑回归快且可解释,朴素贝叶斯在短文本上通常很猛,SVM 对高维稀疏特征上限最高但调参麻烦。对比代码可以做成一个 pipeline:
from sklearn.linear_model import LogisticRegression from sklearn.naive_bayes import MultinomialNB from sklearn.svm import SVC from sklearn.model_selection import cross_val_score # 逻辑回归做基线 lr = LogisticRegression(C=1.0, max_iter=1000, class_weight="balanced") scores = cross_val_score(lr, X_train, train_df["EI"], cv=5, scoring="f1_macro") print("LR EI F1:", scores.mean()) # 朴素贝叶斯注意:它不接受负值输入,前面标准化后的手工特征要单独处理 nb = MultinomialNB() nb_scores = cross_val_score(nb, X_tfidf, train_df["EI"], cv=5, scoring="f1_macro") print("NB EI F1:", nb_scores.mean())逻辑回归里class_weight="balanced"是处理 E/I 失衡问题的第一步,它会自动给少数类更大的惩罚权重。朴素贝叶斯我只用 TF-IDF 特征跑,因为它要求特征值非负,而标准化后的手工特征有负数,硬塞进去会报错或出 NaN。如果你的手工特征有一部分想做进贝叶斯,可以用MinMaxScaler先缩放到 [0,1]。对比的目的不是找最佳模型,而是确认“这个任务的下限在哪”。
3.2 XGBoost 调参:先用默认参数找方向,再小步网格搜索
基线跑完后,我会直接上 XGBoost,它的优势是能同时处理稀疏 TF-IDF 特征和稠密手工特征,而且对特征间的非线性关系拟合得比逻辑回归好。但 XGBoost 参数多,一上来就 GridSearch 很容易让训练时间爆炸。我的习惯是固定learning_rate=0.1和n_estimators=500,然后只搜索max_depth和min_child_weight两个参数,确定结构后再回来调subsample和colsample_bytree。
import xgboost as xgb model = xgb.XGBClassifier( learning_rate=0.1, n_estimators=500, max_depth=5, min_child_weight=1, subsample=0.8, colsample_bytree=0.8, eval_metric="logloss", use_label_encoder=False, verbosity=1 ) model.fit( X_train, train_df["EI"], eval_set=[(X_train, train_df["EI"]), (X_test, test_df["EI"])], early_stopping_rounds=30, verbose=False )关键点全在参数上:eval_metric="logloss"在二分类里比默认的 error 更能反映概率质量;early_stopping_rounds=30用验证集早停,防止 n_estimators=500 跑过头;subsample=0.8让每棵树只看到 80% 样本,降低过拟合。注意use_label_encoder=False是 XGBoost 2.0 之后的必备参数,否则会报警告甚至报错。早停之后,model.best_iteration会记录最优树数量,正式预测时要用它截断。
3.3 四维度独立训练还是共用特征?我的选择与理由
有的实现会用MultiOutputClassifier包一个模型同时预测四个维度,代码短,但问题在于四个维度的最优模型可能不同。我实际测试下来,EI 维度逻辑回归和 XGBoost 差不多,而 JP 维度 XGBoost 明显更好。所以最终方案是四个独立的 XGBoost 模型,每个维度单独调参。代价是训练时间多三倍,但换来的是每个维度都能选择自己最优的早停轮数和正负样本权重。
参数设计上,四个维度共用一组初始参数,只有scale_pos_weight不一样。这个参数的值等于负样本数除以正样本数,它比class_weight="balanced"更直接——它只对少数类放大梯度,不影响多数类的判定边界。比如 TF 维度里 F 只占 32%,那就计算(0.68 / 0.32) ≈ 2.12,把scale_pos_weight=2.12传给模型。
4. 模型评估与界面集成:从离线指标到能用的预测系统
4.1 评估不能只看准确率:四维度分别算 F1 和 AUC
人格预测项目的评估要特别小心准确率陷阱。假设 E/I 维度 E 占 60%,那纯猜 E 的模型准确率就是 60%,看起来不错,实际毫无价值。所以我对每个维度分别打印分类报告,额外看 ROC AUC,最后才看“四维全对”的综合准确率。
from sklearn.metrics import classification_report, roc_auc_score for col, model in models.items(): y_pred = model.predict(X_test_feat[col]) y_proba = model.predict_proba(X_test_feat[col])[:, 1] print(f"===== {col} =====") print(classification_report(test_df[col], y_pred, target_names=["0", "1"])) print("AUC:", roc_auc_score(test_df[col], y_proba))这段代码里我把四个模型存在一个字典里,特征也按维度预处理好。打印出的分类报告里重点看少数类的 precision 和 recall:如果少数类 recall 明显低于 majority 类,说明scale_pos_weight给得还不够;如果 precision 太低,说明模型为了抓少数类误伤太多多数类,需要回调权重。ROC AUC 是阈值无关的指标,在类别失衡时比准确率可靠得多——它能告诉你模型在“排序能力”层面是否有效,哪怕分类阈值选得不好。
4.2 模型持久化与 Flask API:把模型文件变小,加载变快
训练完成后,模型和向量化器都要存下来。这里有个坑:TfidfVectorizer的词汇表很大时,直接joblib.dump可能生成几百 MB 的文件,前端加载一次要卡好几秒。我习惯把max_features压到 5000 以内,并且只在需要时加载模型,不用常驻内存。服务端用 Flask 写一个轻量接口,接收 JSON 文本,返回四个维度概率和最终类型。
from flask import Flask, request, jsonify import joblib app = Flask(__name__) models = {col: joblib.load(f"model_{col}.pkl") for col in ["EI", "SN", "TF", "JP"]} tfidf = joblib.load("tfidf.pkl") scaler = joblib.load("scaler.pkl") @app.route("/predict", methods=["POST"]) def predict(): data = request.get_json() text = data.get("text", "") # 复用训练时的特征构建逻辑 X_tfidf = tfidf.transform([text]) X_manual = scaler.transform(build_manual_features([text])) X_input = hstack([X_tfidf, csr_matrix(X_manual)]) result = {} for col, model in models.items(): proba = model.predict_proba(X_input)[0] result[col] = {"prob0": float(proba[0]), "prob1": float(proba[1])} return jsonify(result) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000)这里的核心是预测接口必须重新走一遍和训练时完全一样的特征流程——先 TF-IDF 变换,再手工特征标准化,再拼接。任何一步顺序错了,特征的列位置对不上,模型输出就全是垃圾。我吃过这个亏:训练时先拼接后标准化,接口里先标准化后拼接,模型预测结果直接崩塌。所以这段代码里我刻意把transform的顺序写清楚,并且用同一个hstack逻辑保证列序一致。
4.3 前端界面:用 JavaScript 调接口,把四维概率可视化
用户界面不需要复杂,一个文本框加一个结果展示区就够了。难点在于如何把四个维度的概率呈现得让普通人看得懂。我采用的是左右条对比:E 侧和 I 侧各一根色条,长度按概率比例填充,肉眼一扫就能看出偏向。类型判定则取四个维度中概率大于 0.5 的标签拼接,比如 E/N/T/J 就是 ENTJ。
async function predictMBTI() { const text = document.getElementById("inputText").value; const res = await fetch("/predict", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: text }) }); const data = await res.json(); const type = (data.EI.prob1 > 0.5 ? "I" : "E") + (data.SN.prob1 > 0.5 ? "N" : "S") + (data.TF.prob1 > 0.5 ? "F" : "T") + (data.JP.prob1 > 0.5 ? "P" : "J"); document.getElementById("resultType").innerText = type; document.getElementById("eibarE").style.width = (data.EI.prob0 * 100).toFixed(1) + "%"; document.getElementById("eibarI").style.width = (data.EI.prob1 * 100).toFixed(1) + "%"; }这段 JS 里的判定阈值 0.5 是默认值,但它不一定最优。如果你的测试集上少数类 recall 偏低,可以往下调到 0.4,让模型更容易标出少数类。阈值调整后只需要改前端一处判断逻辑,后端不用动。界面上除了四维概率条,我还加了一行“模型置信度”,取四个维度中最高概率和最低概率的差值——差值小说明模型对这段文本“拿不准”,这种样本在界面上会提示用户多输入几句话,能显著提升体验。
5. 常见问题与避坑:人格预测项目的五个血泪教训
5.1 现象:16 类直接分类,准确率只有 25% 上下
我第一次做的时候直接把标签变成 16 类多分类,想着一次到位。结果测试集准确率 0.27,跟瞎猜差不多。原因不在于模型差,而在于 16 类中某些类型(如 INFP 和 INFJ)的文本特征空间高度重叠,样本量又不足,模型根本学不到稳定边界。解决方法是改成四维度独立二分类后,每个维度 F1 都提升到 0.7 以上,综合四维全对准确率大约到 35%~45%。这个数字单看不惊艳,但在人格预测任务里已经算可用水平,因为四维全对的偶然概率本来就是 1/16 ≈ 6%。
5.2 现象:训练用了好模型,预测时却全是同一类
表现:测试集指标还行,但接口返回的预测结果几乎全是“I/S/T/J”,或者某个维度永远偏向训练数据里的多数类。原因有两种:一是没设scale_pos_weight或class_weight,模型直接学成“全部预测多数类”;二是训练集和测试集混合了同一个人的多条文本,模型记住了人而不是文本风格。解决方法是先给每个维度加scale_pos_weight,再回到前面的用户分组切分逻辑,按用户 ID 重新划分数据。
5.3 现象:模型能跑,但 AUC 低于 0.55,等于白练
AUC 过低说明模型排序能力不行,问题大概率出在特征上。常见情况是 TF-IDF 参数设得太宽(max_features=20000、不加min_df),把大量只在一条文本里出现的生僻词塞进特征矩阵,模型学的全是噪音。解决方法是把min_df提到 3~5,max_features降到 3000~5000,重新训练。如果降完特征维度后 AUC 反而上升,说明之前确实过拟合了。
5.4 现象:清洗时把所有符号去掉,模型效果反而下降
很多人清洗文本时会顺手把标点、表情符号全删掉,这是人格预测的大忌。问号、感叹号、省略号本身就是表达风格的载体——我用过的数据集里,外向型文本的感叹号密度显著高于内向型,情绪化文本省略号更多。解决方法是分层清洗:错别字和 URL 可以清,标点保留并单独做成密度特征,表情符号转成特殊 token 或直接统计数量。
5.5 现象:后端模型加载慢,接口超时
joblib.load一个 800 MB 的 XGBoost 模型文件,每次启动要十几秒,第一次请求直接超时。原因是为了追求精度把n_estimators拉到 2000,且max_depth=10,模型文件巨大。解决方法是训练时用early_stopping_rounds控制树数量,预测前用model.prune或重新保存best_iteration对应的模型。如果模型文件仍然大,就把n_estimators压到 300 以内,max_depth压到 6,通常精度损失不超过 3 个点,加载速度却快几倍。
6. 进阶验证:用 SHAP 解释模型,让预测结果不再是黑匣子
模型上线后,用户会问“为什么你判定我是 ENFP 而不是 ENFJ”。这时候 SHAP 是目前最直观的工具。它能把每条预测结果拆解成“哪些词把概率往 E 方向推,哪些词往 I 方向推”,不仅给用户解释,也能帮你发现模型有没有学偏。
import shap explainer = shap.TreeExplainer(models["EI"]) X_test_dense = X_test_feat["EI"].toarray() if hasattr(X_test_feat["EI"], "toarray") else X_test_feat["EI"] shap_values = explainer.shap_values(X_test_dense[:50]) feature_names = tfidf.get_feature_names_out().tolist() + [ "text_len", "avg_sent_len", "exclaim_density", "question_density", "first_person", "digit_density" ] # 用文本解释器查看单条预测 shap.initjs() shap.force_plot(explainer.expected_value, shap_values[0], feature_names=feature_names)SHAP 的TreeExplainer只适用于树模型,逻辑回归可以用LinearExplainer。注意我把手工特征的 6 个名字拼到了 TF-IDF 特征名后面,这样 force plot 里才能正确显示列名。跑完会在浏览器里看到一条彩带图:红色区域是把预测往“1 类”(比如内向 I)推的特征,蓝色区域是往“0 类”(外向 E)推的特征。我拿着这个图去核对数据集里一些被模型强判为 I 的文本,发现高频出现的“我觉得”“可能”“或许”这类词确实都排在前面,说明模型学的不是表面词形,而是表达风格,这才敢把系统放出去。
SHAP 还有个别名作用:定位坏样本。如果某条测试文本 SHAP 显示模型主要靠一个词拉到某侧,比如文本里出现一次“哈哈”就被判定为外向,那说明特征里有单点敏感问题。我后来加了一条规则:模型输出的置信度低于 0.6 时,界面提示“请再输入 2~3 句话”,实际用下来能把用户投诉率压下去不少。从那以后我每次训练完新模型,都要强制跑一遍 50 条测试样本的 SHAP 输出,逐个看 top 3 特征是否符合直觉,再做发布。这个习惯帮我拦下过两次因为标签映射错误导致的“模型学对了但标签反了”的事故。希望这个从数据清洗到解释性验证的完整流程也能帮到你,少踩几次我踩过的坑。
本文还有配套的精品资源,点击获取