Polish absence analysis demo experience
This commit is contained in:
@@ -43,14 +43,14 @@ class KMeansAnalyzer:
|
|||||||
center = centers[int(cluster_id)]
|
center = centers[int(cluster_id)]
|
||||||
clusters.append({
|
clusters.append({
|
||||||
'id': int(cluster_id),
|
'id': int(cluster_id),
|
||||||
'name': names.get(int(cluster_id), f'群体{int(cluster_id) + 1}'),
|
'name': names.get(int(cluster_id), '常规稳态型'),
|
||||||
'member_count': int(count),
|
'member_count': int(count),
|
||||||
'percentage': round(count / total * 100, 1),
|
'percentage': round(count / total * 100, 1),
|
||||||
'center': {
|
'center': {
|
||||||
feature: round(float(value), 2)
|
feature: round(float(value), 2)
|
||||||
for feature, value in zip(self.feature_cols, center)
|
for feature, value in zip(self.feature_cols, center)
|
||||||
},
|
},
|
||||||
'description': self._generate_description(names.get(int(cluster_id), '')),
|
'description': self._generate_description(names.get(int(cluster_id), '常规稳态型'), center),
|
||||||
})
|
})
|
||||||
return {'n_clusters': self.n_clusters, 'clusters': clusters}
|
return {'n_clusters': self.n_clusters, 'clusters': clusters}
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ class KMeansAnalyzer:
|
|||||||
'clusters': [
|
'clusters': [
|
||||||
{
|
{
|
||||||
'id': idx,
|
'id': idx,
|
||||||
'name': names.get(idx, f'群体{idx + 1}'),
|
'name': names.get(idx, '常规稳态型'),
|
||||||
'values': [round(float(v), 2) for v in centers_scaled[idx]],
|
'values': [round(float(v), 2) for v in centers_scaled[idx]],
|
||||||
}
|
}
|
||||||
for idx in range(self.n_clusters)
|
for idx in range(self.n_clusters)
|
||||||
@@ -105,27 +105,63 @@ class KMeansAnalyzer:
|
|||||||
'4': '#6DC8EC',
|
'4': '#6DC8EC',
|
||||||
},
|
},
|
||||||
'cluster_names': {
|
'cluster_names': {
|
||||||
str(idx): names.get(idx, f'群体{idx + 1}')
|
str(idx): names.get(idx, '常规稳态型')
|
||||||
for idx in range(self.n_clusters)
|
for idx in range(self.n_clusters)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def _generate_cluster_names(self, centers):
|
def _generate_cluster_names(self, centers):
|
||||||
|
rank_info = self._build_rank_info(centers)
|
||||||
base_names = {}
|
base_names = {}
|
||||||
for idx, center in enumerate(centers):
|
for idx, center in enumerate(centers):
|
||||||
_, tenure, overtime, commute, bmi, absence = center
|
base_names[idx] = self._classify_cluster(center, rank_info, idx)
|
||||||
if overtime > 38 and commute > 55 and absence > 8:
|
|
||||||
base_names[idx] = '高压通勤型'
|
|
||||||
elif bmi > 27 and absence > 8:
|
|
||||||
base_names[idx] = '健康波动型'
|
|
||||||
elif tenure > 8 and absence < 6:
|
|
||||||
base_names[idx] = '稳定低风险型'
|
|
||||||
elif overtime > 28 and absence > 7:
|
|
||||||
base_names[idx] = '轮班负荷型'
|
|
||||||
else:
|
|
||||||
base_names[idx] = f'群体{idx + 1}'
|
|
||||||
return self._deduplicate_cluster_names(base_names, centers)
|
return self._deduplicate_cluster_names(base_names, centers)
|
||||||
|
|
||||||
|
def _build_rank_info(self, centers):
|
||||||
|
centers = np.asarray(centers, dtype=float)
|
||||||
|
return {
|
||||||
|
'年龄': self._rank_desc(centers[:, 0]),
|
||||||
|
'司龄': self._rank_desc(centers[:, 1]),
|
||||||
|
'加班': self._rank_desc(centers[:, 2]),
|
||||||
|
'通勤': self._rank_desc(centers[:, 3]),
|
||||||
|
'BMI': self._rank_desc(centers[:, 4]),
|
||||||
|
'缺勤': self._rank_desc(centers[:, 5]),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _rank_desc(self, values):
|
||||||
|
ordered = np.argsort(-np.asarray(values, dtype=float))
|
||||||
|
ranks = {}
|
||||||
|
for rank, idx in enumerate(ordered):
|
||||||
|
ranks[int(idx)] = rank
|
||||||
|
return ranks
|
||||||
|
|
||||||
|
def _classify_cluster(self, center, rank_info, idx):
|
||||||
|
age, tenure, overtime, commute, bmi, absence = center
|
||||||
|
high_absence = rank_info['缺勤'][idx] == 0
|
||||||
|
low_absence = rank_info['缺勤'][idx] == len(rank_info['缺勤']) - 1
|
||||||
|
high_overtime = rank_info['加班'][idx] <= 1
|
||||||
|
high_commute = rank_info['通勤'][idx] <= 1
|
||||||
|
high_bmi = rank_info['BMI'][idx] <= 1
|
||||||
|
high_tenure = rank_info['司龄'][idx] <= 1
|
||||||
|
low_tenure = rank_info['司龄'][idx] >= len(rank_info['司龄']) - 1
|
||||||
|
young_group = rank_info['年龄'][idx] >= len(rank_info['年龄']) - 1
|
||||||
|
|
||||||
|
if (absence >= 7.5 and overtime >= 28 and commute >= 40) or (high_absence and high_overtime and high_commute):
|
||||||
|
return '压力奔波型'
|
||||||
|
if (absence >= 7.0 and bmi >= 25.5) or (high_absence and high_bmi):
|
||||||
|
return '健康关注型'
|
||||||
|
if (overtime >= 30 and absence >= 6.0) or (high_overtime and rank_info['缺勤'][idx] <= 1):
|
||||||
|
return '负荷承压型'
|
||||||
|
if (tenure >= 8 and absence <= 6.0) or (high_tenure and low_absence):
|
||||||
|
return '稳定成熟型'
|
||||||
|
if (tenure <= 4 and age <= 32) or (low_tenure and young_group):
|
||||||
|
return '新锐成长型'
|
||||||
|
if commute <= 35 and absence <= 6.5:
|
||||||
|
return '通勤平衡型'
|
||||||
|
if tenure >= 6 and absence <= 6.8:
|
||||||
|
return '经验稳健型'
|
||||||
|
return '常规稳态型'
|
||||||
|
|
||||||
def _deduplicate_cluster_names(self, names, centers):
|
def _deduplicate_cluster_names(self, names, centers):
|
||||||
grouped = {}
|
grouped = {}
|
||||||
for idx, name in names.items():
|
for idx, name in names.items():
|
||||||
@@ -159,24 +195,75 @@ class KMeansAnalyzer:
|
|||||||
|
|
||||||
def _suffix_candidates(self, name):
|
def _suffix_candidates(self, name):
|
||||||
suffix_map = {
|
suffix_map = {
|
||||||
'高压通勤型': ['-高风险组', '-关注组', '-观察组'],
|
'压力奔波型': ['-高压组', '-长途组', '-持续关注组'],
|
||||||
'健康波动型': ['-重点关注组', '-预警组', '-观察组'],
|
'健康关注型': ['-重点关注组', '-预警组', '-干预组'],
|
||||||
'稳定低风险型': ['-资深组', '-成熟组', '-稳健组'],
|
'负荷承压型': ['-高负荷组', '-轮班组', '-调节组'],
|
||||||
'轮班负荷型': ['-高负荷组', '-轮班组', '-强化组'],
|
'稳定成熟型': ['-资深组', '-成熟组', '-稳健组'],
|
||||||
|
'新锐成长型': ['-适应组', '-成长组', '-潜力组'],
|
||||||
|
'通勤平衡型': ['-均衡组', '-稳态组', '-协同组'],
|
||||||
|
'经验稳健型': ['-资深组', '-稳健组', '-协同组'],
|
||||||
|
'常规稳态型': ['-平衡组', '-常态组', '-协同组'],
|
||||||
}
|
}
|
||||||
return suffix_map.get(name, [f'({idx})' for idx in range(1, 10)])
|
return suffix_map.get(name, [f'({idx})' for idx in range(1, 10)])
|
||||||
|
|
||||||
def _generate_description(self, name):
|
def _generate_description(self, name, center=None):
|
||||||
descriptions = {
|
descriptions = {
|
||||||
'高压通勤型': '加班和通勤压力都高,缺勤时长偏长。',
|
'压力奔波型': '加班与通勤压力同时偏高,缺勤波动更明显。',
|
||||||
'健康波动型': '健康相关风险更高,需要重点关注。',
|
'健康关注型': '健康负担更突出,缺勤时长偏高,建议优先关注。',
|
||||||
'稳定低风险型': '司龄较长,缺勤水平稳定且偏低。',
|
'负荷承压型': '工作负荷较重,缺勤风险处于偏高水平。',
|
||||||
'轮班负荷型': '排班和工作负荷较重,缺勤风险较高。',
|
'稳定成熟型': '司龄较长,整体状态稳定,缺勤水平偏低。',
|
||||||
|
'新锐成长型': '整体更年轻、司龄较短,仍处于适应与成长阶段。',
|
||||||
|
'通勤平衡型': '通勤与缺勤表现较均衡,整体波动相对可控。',
|
||||||
|
'经验稳健型': '具备一定经验积累,整体表现稳健,缺勤风险较低。',
|
||||||
|
'常规稳态型': '整体表现接近企业常态,是较典型的员工群体。',
|
||||||
}
|
}
|
||||||
for key, description in descriptions.items():
|
for key, description in descriptions.items():
|
||||||
if name.startswith(key):
|
if name.startswith(key):
|
||||||
|
if center is None:
|
||||||
return description
|
return description
|
||||||
return descriptions.get(name, '常规员工群体。')
|
return self._build_dynamic_description(key, center, description)
|
||||||
|
return descriptions.get(name, '整体表现接近企业常态。')
|
||||||
|
|
||||||
|
def _build_dynamic_description(self, base_name, center, default_description):
|
||||||
|
age, tenure, overtime, commute, bmi, absence = center
|
||||||
|
clauses = []
|
||||||
|
|
||||||
|
if tenure >= 8:
|
||||||
|
clauses.append('司龄较长')
|
||||||
|
elif tenure <= 4:
|
||||||
|
clauses.append('司龄较短')
|
||||||
|
|
||||||
|
if overtime >= 30:
|
||||||
|
clauses.append('加班负荷偏高')
|
||||||
|
elif overtime <= 18:
|
||||||
|
clauses.append('加班压力相对可控')
|
||||||
|
|
||||||
|
if commute >= 45:
|
||||||
|
clauses.append('通勤压力偏高')
|
||||||
|
elif commute <= 30:
|
||||||
|
clauses.append('通勤节奏较平衡')
|
||||||
|
|
||||||
|
if bmi >= 26:
|
||||||
|
clauses.append('健康管理压力更明显')
|
||||||
|
|
||||||
|
if absence >= 7.5:
|
||||||
|
clauses.append('缺勤时长偏高')
|
||||||
|
elif absence <= 5.5:
|
||||||
|
clauses.append('缺勤水平偏低')
|
||||||
|
|
||||||
|
if age <= 32:
|
||||||
|
clauses.append('群体整体更年轻')
|
||||||
|
elif age >= 40:
|
||||||
|
clauses.append('群体整体更成熟')
|
||||||
|
|
||||||
|
unique_clauses = []
|
||||||
|
for clause in clauses:
|
||||||
|
if clause not in unique_clauses:
|
||||||
|
unique_clauses.append(clause)
|
||||||
|
|
||||||
|
if not unique_clauses:
|
||||||
|
return default_description
|
||||||
|
return ','.join(unique_clauses[:3]) + '。'
|
||||||
|
|
||||||
|
|
||||||
kmeans_analyzer = KMeansAnalyzer()
|
kmeans_analyzer = KMeansAnalyzer()
|
||||||
|
|||||||
@@ -2,17 +2,21 @@ from core.clustering import KMeansAnalyzer
|
|||||||
|
|
||||||
|
|
||||||
class ClusterService:
|
class ClusterService:
|
||||||
def __init__(self):
|
def _create_analyzer(self):
|
||||||
self.analyzer = KMeansAnalyzer()
|
# 聚类接口会被前端并发调用,避免复用同一个可变分析器实例导致结果串线。
|
||||||
|
return KMeansAnalyzer()
|
||||||
|
|
||||||
def get_cluster_result(self, n_clusters=3):
|
def get_cluster_result(self, n_clusters=3):
|
||||||
return self.analyzer.get_cluster_results(n_clusters)
|
analyzer = self._create_analyzer()
|
||||||
|
return analyzer.get_cluster_results(n_clusters)
|
||||||
|
|
||||||
def get_cluster_profile(self, n_clusters=3):
|
def get_cluster_profile(self, n_clusters=3):
|
||||||
return self.analyzer.get_cluster_profile(n_clusters)
|
analyzer = self._create_analyzer()
|
||||||
|
return analyzer.get_cluster_profile(n_clusters)
|
||||||
|
|
||||||
def get_scatter_data(self, n_clusters=3, x_axis='月均加班时长', y_axis='缺勤时长(小时)'):
|
def get_scatter_data(self, n_clusters=3, x_axis='月均加班时长', y_axis='缺勤时长(小时)'):
|
||||||
return self.analyzer.get_scatter_data(n_clusters, x_axis, y_axis)
|
analyzer = self._create_analyzer()
|
||||||
|
return analyzer.get_scatter_data(n_clusters, x_axis, y_axis)
|
||||||
|
|
||||||
|
|
||||||
cluster_service = ClusterService()
|
cluster_service = ClusterService()
|
||||||
|
|||||||
@@ -16,18 +16,26 @@ from core.model_features import (
|
|||||||
|
|
||||||
MODEL_INFO = {
|
MODEL_INFO = {
|
||||||
'random_forest': {'name': 'random_forest', 'name_cn': '随机森林', 'description': '稳健的树模型集成'},
|
'random_forest': {'name': 'random_forest', 'name_cn': '随机森林', 'description': '稳健的树模型集成'},
|
||||||
'xgboost': {'name': 'xgboost', 'name_cn': 'XGBoost', 'description': '梯度提升树模型'},
|
'xgboost': {'name': 'xgboost', 'name_cn': '增强树模型一', 'description': '梯度提升树模型'},
|
||||||
'lightgbm': {'name': 'lightgbm', 'name_cn': 'LightGBM', 'description': '轻量级梯度提升树'},
|
'lightgbm': {'name': 'lightgbm', 'name_cn': '增强树模型二', 'description': '轻量级梯度提升树'},
|
||||||
'gradient_boosting': {'name': 'gradient_boosting', 'name_cn': 'GBDT', 'description': '梯度提升决策树'},
|
'gradient_boosting': {'name': 'gradient_boosting', 'name_cn': '梯度提升树', 'description': '梯度提升决策树'},
|
||||||
'extra_trees': {'name': 'extra_trees', 'name_cn': '极端随机树', 'description': '高随机性的树模型'},
|
'extra_trees': {'name': 'extra_trees', 'name_cn': '极端随机树', 'description': '高随机性的树模型'},
|
||||||
'stacking': {'name': 'stacking', 'name_cn': 'Stacking集成', 'description': '多模型融合'},
|
'stacking': {'name': 'stacking', 'name_cn': '集成模型', 'description': '多模型融合'},
|
||||||
'lstm_mlp': {
|
'lstm_mlp': {
|
||||||
'name': 'lstm_mlp',
|
'name': 'lstm_mlp',
|
||||||
'name_cn': '时序注意力融合网络',
|
'name_cn': '时序注意力融合网络',
|
||||||
'description': 'Transformer时序编码 + 静态特征门控融合的深度学习模型',
|
'description': 'Transformer 时序编码与静态特征融合的深度学习模型',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EXPLAINABLE_TREE_MODELS = (
|
||||||
|
'random_forest',
|
||||||
|
'xgboost',
|
||||||
|
'lightgbm',
|
||||||
|
'gradient_boosting',
|
||||||
|
'extra_trees',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PredictService:
|
class PredictService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -96,7 +104,6 @@ class PredictService:
|
|||||||
if valid_metrics:
|
if valid_metrics:
|
||||||
self.default_model = max(valid_metrics.items(), key=lambda item: item[1]['r2'])[0]
|
self.default_model = max(valid_metrics.items(), key=lambda item: item[1]['r2'])[0]
|
||||||
|
|
||||||
# 加载风险分类模型
|
|
||||||
for name in ['random_forest', 'gradient_boosting', 'lightgbm', 'xgboost']:
|
for name in ['random_forest', 'gradient_boosting', 'lightgbm', 'xgboost']:
|
||||||
path = os.path.join(config.MODELS_DIR, f'risk_{name}_classifier.pkl')
|
path = os.path.join(config.MODELS_DIR, f'risk_{name}_classifier.pkl')
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
@@ -123,18 +130,22 @@ class PredictService:
|
|||||||
models.sort(key=lambda item: item['metrics']['r2'], reverse=True)
|
models.sort(key=lambda item: item['metrics']['r2'], reverse=True)
|
||||||
return models
|
return models
|
||||||
|
|
||||||
def predict_single(self, data, model_type=None):
|
def predict_single(self, data, model_type=None, include_explanation=True):
|
||||||
self._ensure_models_loaded()
|
self._ensure_models_loaded()
|
||||||
model_type = model_type or self.default_model
|
model_type = self._resolve_prediction_model(model_type or self.default_model)
|
||||||
if model_type not in self.models:
|
_, engineered_df = self._build_prediction_frames(data)
|
||||||
fallback = next(iter(self.models), None)
|
engineered_row = engineered_df.iloc[0]
|
||||||
if fallback is None:
|
|
||||||
return self._get_default_prediction(data)
|
if model_type is None or self.scaler is None or self.feature_names is None:
|
||||||
model_type = fallback
|
result = self._get_default_prediction(data)
|
||||||
if self.scaler is None or self.feature_names is None:
|
return self._augment_prediction_result(result, data, engineered_row) if include_explanation else result
|
||||||
return self._get_default_prediction(data)
|
|
||||||
|
try:
|
||||||
|
features = self._prepare_features_from_engineered(engineered_df)
|
||||||
|
except Exception:
|
||||||
|
result = self._get_default_prediction(data)
|
||||||
|
return self._augment_prediction_result(result, data, engineered_row) if include_explanation else result
|
||||||
|
|
||||||
features = self._prepare_features(data)
|
|
||||||
try:
|
try:
|
||||||
if model_type == 'lstm_mlp':
|
if model_type == 'lstm_mlp':
|
||||||
current_df = build_prediction_dataframe(data)
|
current_df = build_prediction_dataframe(data)
|
||||||
@@ -144,15 +155,14 @@ class PredictService:
|
|||||||
predicted_hours = self._inverse_transform_prediction(predicted_hours)
|
predicted_hours = self._inverse_transform_prediction(predicted_hours)
|
||||||
predicted_hours = max(0.5, float(predicted_hours))
|
predicted_hours = max(0.5, float(predicted_hours))
|
||||||
except Exception:
|
except Exception:
|
||||||
return self._get_default_prediction(data)
|
result = self._get_default_prediction(data)
|
||||||
|
return self._augment_prediction_result(result, data, engineered_row) if include_explanation else result
|
||||||
|
|
||||||
risk_level, risk_label = self._get_risk_level(predicted_hours)
|
risk_level, risk_label = self._get_risk_level(predicted_hours)
|
||||||
confidence = max(0.5, self.model_metrics.get(model_type, {}).get('r2', 0.82))
|
confidence = max(0.5, self.model_metrics.get(model_type, {}).get('r2', 0.82))
|
||||||
|
|
||||||
# 风险分类概率
|
|
||||||
risk_probability = self._get_risk_probability(features, model_type)
|
risk_probability = self._get_risk_probability(features, model_type)
|
||||||
|
|
||||||
return {
|
result = {
|
||||||
'predicted_hours': round(predicted_hours, 2),
|
'predicted_hours': round(predicted_hours, 2),
|
||||||
'risk_level': risk_level,
|
'risk_level': risk_level,
|
||||||
'risk_label': risk_label,
|
'risk_label': risk_label,
|
||||||
@@ -161,12 +171,13 @@ class PredictService:
|
|||||||
'model_used': model_type,
|
'model_used': model_type,
|
||||||
'model_name_cn': MODEL_INFO.get(model_type, {}).get('name_cn', model_type),
|
'model_name_cn': MODEL_INFO.get(model_type, {}).get('name_cn', model_type),
|
||||||
}
|
}
|
||||||
|
return self._augment_prediction_result(result, data, engineered_row) if include_explanation else result
|
||||||
|
|
||||||
def predict_compare(self, data):
|
def predict_compare(self, data):
|
||||||
self._ensure_models_loaded()
|
self._ensure_models_loaded()
|
||||||
results = []
|
results = []
|
||||||
for name in self.models.keys():
|
for name in self.models.keys():
|
||||||
result = self.predict_single(data, name)
|
result = self.predict_single(data, name, include_explanation=False)
|
||||||
result['model'] = name
|
result['model'] = name
|
||||||
result['model_name_cn'] = MODEL_INFO.get(name, {}).get('name_cn', name)
|
result['model_name_cn'] = MODEL_INFO.get(name, {}).get('name_cn', name)
|
||||||
result['r2'] = self.model_metrics.get(name, {}).get('r2', 0)
|
result['r2'] = self.model_metrics.get(name, {}).get('r2', 0)
|
||||||
@@ -176,10 +187,17 @@ class PredictService:
|
|||||||
results[0]['recommended'] = True
|
results[0]['recommended'] = True
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
def _build_prediction_frames(self, data):
|
||||||
|
current_df = build_prediction_dataframe(data)
|
||||||
|
engineered_df = engineer_features(current_df.copy())
|
||||||
|
return current_df, engineered_df
|
||||||
|
|
||||||
def _prepare_features(self, data):
|
def _prepare_features(self, data):
|
||||||
X_df = build_prediction_dataframe(data)
|
_, engineered_df = self._build_prediction_frames(data)
|
||||||
X_df = engineer_features(X_df)
|
return self._prepare_features_from_engineered(engineered_df)
|
||||||
X_df = apply_label_encoders(X_df, self.label_encoders)
|
|
||||||
|
def _prepare_features_from_engineered(self, engineered_df):
|
||||||
|
X_df = apply_label_encoders(engineered_df.copy(), self.label_encoders)
|
||||||
X_df = align_feature_frame(X_df, self.feature_names)
|
X_df = align_feature_frame(X_df, self.feature_names)
|
||||||
features = self.scaler.transform(to_float_array(X_df))[0]
|
features = self.scaler.transform(to_float_array(X_df))[0]
|
||||||
if self.selected_features:
|
if self.selected_features:
|
||||||
@@ -188,6 +206,338 @@ class PredictService:
|
|||||||
features = features[selected_indices]
|
features = features[selected_indices]
|
||||||
return features
|
return features
|
||||||
|
|
||||||
|
def _resolve_prediction_model(self, requested_model):
|
||||||
|
if requested_model in self.models:
|
||||||
|
return requested_model
|
||||||
|
if self.default_model in self.models:
|
||||||
|
return self.default_model
|
||||||
|
return next(iter(self.models), None)
|
||||||
|
|
||||||
|
def _resolve_explanation_model(self, prediction_model):
|
||||||
|
if prediction_model in EXPLAINABLE_TREE_MODELS and prediction_model in self.models:
|
||||||
|
return prediction_model
|
||||||
|
for candidate in ('random_forest', 'xgboost', 'lightgbm', 'gradient_boosting', 'extra_trees'):
|
||||||
|
if candidate in self.models:
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _augment_prediction_result(self, result, data, engineered_row):
|
||||||
|
explanation_model = self._resolve_explanation_model(result.get('model_used'))
|
||||||
|
shap_local = self._get_local_explanation(data, explanation_model)
|
||||||
|
jdr_snapshot = self._build_jdr_snapshot(engineered_row)
|
||||||
|
mechanism_summary = self._build_mechanism_summary(result, data, jdr_snapshot, shap_local)
|
||||||
|
intervention_suggestions = self._build_intervention_suggestions(data, jdr_snapshot, shap_local)
|
||||||
|
|
||||||
|
payload = dict(result)
|
||||||
|
payload.update({
|
||||||
|
'jdr_snapshot': jdr_snapshot,
|
||||||
|
'mechanism_summary': mechanism_summary,
|
||||||
|
'intervention_suggestions': intervention_suggestions,
|
||||||
|
'explanation_model_used': explanation_model,
|
||||||
|
'explanation_model_name_cn': MODEL_INFO.get(explanation_model, {}).get('name_cn', '机制解释'),
|
||||||
|
'shap_local': shap_local,
|
||||||
|
})
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def _get_local_explanation(self, data, model_type):
|
||||||
|
if not model_type:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from services.shap_service import shap_service
|
||||||
|
|
||||||
|
explanation = shap_service.get_local_explanation(data, model_type)
|
||||||
|
if explanation and not explanation.get('error'):
|
||||||
|
return explanation
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _build_jdr_snapshot(self, engineered_row):
|
||||||
|
snapshot = {
|
||||||
|
'job_demands': self._build_snapshot_item(
|
||||||
|
'job_demands',
|
||||||
|
'工作要求',
|
||||||
|
engineered_row.get('工作要求指数', 0.0),
|
||||||
|
*self._classify_job_demands(engineered_row.get('工作要求指数', 0.0)),
|
||||||
|
),
|
||||||
|
'job_resources': self._build_snapshot_item(
|
||||||
|
'job_resources',
|
||||||
|
'工作资源',
|
||||||
|
engineered_row.get('工作资源指数', 0.0),
|
||||||
|
*self._classify_resource_stock(engineered_row.get('工作资源指数', 0.0)),
|
||||||
|
),
|
||||||
|
'personal_resources': self._build_snapshot_item(
|
||||||
|
'personal_resources',
|
||||||
|
'个人资源',
|
||||||
|
engineered_row.get('个人资源指数', 0.0),
|
||||||
|
*self._classify_resource_stock(engineered_row.get('个人资源指数', 0.0)),
|
||||||
|
),
|
||||||
|
'balance': self._build_snapshot_item(
|
||||||
|
'balance',
|
||||||
|
'平衡度',
|
||||||
|
engineered_row.get('JD-R平衡度', 0.0),
|
||||||
|
*self._classify_balance(engineered_row.get('JD-R平衡度', 0.0)),
|
||||||
|
),
|
||||||
|
'burnout_risk': self._build_snapshot_item(
|
||||||
|
'burnout_risk',
|
||||||
|
'倦怠风险',
|
||||||
|
engineered_row.get('倦怠风险指数', 0.0),
|
||||||
|
*self._classify_burnout(engineered_row.get('倦怠风险指数', 0.0)),
|
||||||
|
),
|
||||||
|
'engagement': self._build_snapshot_item(
|
||||||
|
'engagement',
|
||||||
|
'工作投入',
|
||||||
|
engineered_row.get('工作投入指数', 0.0),
|
||||||
|
*self._classify_resource_stock(engineered_row.get('工作投入指数', 0.0)),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def _build_snapshot_item(self, key, label, score, status, tone):
|
||||||
|
return {
|
||||||
|
'key': key,
|
||||||
|
'label': label,
|
||||||
|
'score': round(self._safe_float(score), 2),
|
||||||
|
'status': status,
|
||||||
|
'tone': tone,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_mechanism_summary(self, result, data, jdr_snapshot, shap_local):
|
||||||
|
dimension_scores = self._extract_dimension_scores(shap_local)
|
||||||
|
top_drivers = self._extract_feature_effects(shap_local, positive=True, limit=3)
|
||||||
|
protective_factors = self._extract_feature_effects(shap_local, positive=False, limit=2)
|
||||||
|
|
||||||
|
pathway_label, pathway_tone, pathway_detail = self._infer_pathway(jdr_snapshot, dimension_scores)
|
||||||
|
mechanism = self._build_mechanism_text(data, jdr_snapshot, dimension_scores, top_drivers)
|
||||||
|
buffer_text = self._build_buffer_text(jdr_snapshot, protective_factors)
|
||||||
|
scenario_hint = self._build_scenario_hint(data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'conclusion': f"本次预测为{result['risk_label']},预计缺勤时长约 {result['predicted_hours']} 小时。",
|
||||||
|
'mechanism': mechanism,
|
||||||
|
'pathway_label': pathway_label,
|
||||||
|
'pathway_tone': pathway_tone,
|
||||||
|
'pathway_detail': pathway_detail,
|
||||||
|
'buffer_text': buffer_text,
|
||||||
|
'scenario_hint': scenario_hint,
|
||||||
|
'top_drivers': top_drivers,
|
||||||
|
'protective_factors': protective_factors,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_mechanism_text(self, data, jdr_snapshot, dimension_scores, top_drivers):
|
||||||
|
if top_drivers:
|
||||||
|
driver_names = '、'.join(item['name_cn'] for item in top_drivers)
|
||||||
|
if dimension_scores.get('工作要求', 0.0) > 0.03:
|
||||||
|
return f'主要推高因素集中在{driver_names},说明高工作要求正在直接抬升本次缺勤风险。'
|
||||||
|
if dimension_scores.get('事件上下文', 0.0) > 0.03:
|
||||||
|
return f'主要推高因素集中在{driver_names},当前结果更容易受到请假事件情境的直接触发。'
|
||||||
|
if dimension_scores.get('工作资源', 0.0) > 0.03 or dimension_scores.get('个人资源', 0.0) > 0.03:
|
||||||
|
return f'主要推高因素集中在{driver_names},说明资源缓冲不足正在放大本次缺勤时长。'
|
||||||
|
return f'主要推高因素集中在{driver_names},它们共同推动了本次缺勤时长上升。'
|
||||||
|
|
||||||
|
fragments = []
|
||||||
|
if jdr_snapshot['job_demands']['tone'] in {'warning', 'danger'}:
|
||||||
|
fragments.append('工作要求偏高')
|
||||||
|
if jdr_snapshot['job_resources']['tone'] == 'danger':
|
||||||
|
fragments.append('工作资源不足')
|
||||||
|
if jdr_snapshot['personal_resources']['tone'] == 'danger':
|
||||||
|
fragments.append('个人资源偏弱')
|
||||||
|
if self._as_flag(data.get('medical_certificate_flag')) or self._as_flag(data.get('near_holiday_flag')):
|
||||||
|
fragments.append('事件情境触发明显')
|
||||||
|
if not fragments:
|
||||||
|
return '当前结果更多体现为常规缺勤波动,整体压力与资源结构暂时可控。'
|
||||||
|
return f"当前结果主要由{'、'.join(fragments)}共同驱动。"
|
||||||
|
|
||||||
|
def _build_buffer_text(self, jdr_snapshot, protective_factors):
|
||||||
|
if protective_factors:
|
||||||
|
names = '、'.join(item['name_cn'] for item in protective_factors)
|
||||||
|
return f'{names}对当前风险仍有一定缓冲作用,但尚不足以完全抵消主要压力来源。'
|
||||||
|
if jdr_snapshot['job_resources']['tone'] in {'success', 'info'} and jdr_snapshot['personal_resources']['tone'] in {'success', 'info'}:
|
||||||
|
return '当前资源支持和个人恢复能力对风险有一定缓冲,但事件性因素仍需持续关注。'
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def _build_scenario_hint(self, data):
|
||||||
|
actions = []
|
||||||
|
if self._safe_float(data.get('monthly_overtime_hours', 0.0)) >= 25:
|
||||||
|
actions.append('将月均加班控制在 20 小时以内')
|
||||||
|
if self._safe_float(data.get('commute_minutes', 0.0)) >= 45:
|
||||||
|
actions.append('把通勤时长压缩到 30 分钟左右')
|
||||||
|
if self._as_flag(data.get('is_night_shift')):
|
||||||
|
actions.append('减少连续夜班或延长轮休恢复时间')
|
||||||
|
if not actions:
|
||||||
|
return ''
|
||||||
|
if len(actions) == 1:
|
||||||
|
return f'情境判断:若能{actions[0]},当前风险通常会有所回落。'
|
||||||
|
return f"情境判断:若能{',并'.join(actions[:-1])},同时{actions[-1]},当前风险通常会有所回落。"
|
||||||
|
|
||||||
|
def _infer_pathway(self, jdr_snapshot, dimension_scores):
|
||||||
|
demands_pressure = dimension_scores.get('工作要求', 0.0)
|
||||||
|
mediator_pressure = dimension_scores.get('中介变量', 0.0)
|
||||||
|
resource_pressure = dimension_scores.get('工作资源', 0.0) + dimension_scores.get('个人资源', 0.0)
|
||||||
|
event_pressure = dimension_scores.get('事件上下文', 0.0)
|
||||||
|
|
||||||
|
demands_high = jdr_snapshot['job_demands']['tone'] == 'danger'
|
||||||
|
burnout_high = jdr_snapshot['burnout_risk']['tone'] in {'warning', 'danger'}
|
||||||
|
resources_low = (
|
||||||
|
jdr_snapshot['job_resources']['tone'] == 'danger'
|
||||||
|
or jdr_snapshot['personal_resources']['tone'] == 'danger'
|
||||||
|
or jdr_snapshot['engagement']['tone'] == 'danger'
|
||||||
|
)
|
||||||
|
|
||||||
|
if demands_high or burnout_high or demands_pressure > 0.03 or mediator_pressure > 0.03:
|
||||||
|
if resources_low or resource_pressure > 0.03:
|
||||||
|
return (
|
||||||
|
'健康损耗与资源缓冲不足',
|
||||||
|
'danger',
|
||||||
|
'当前结果同时表现出高要求累积与资源缓冲不足,更接近“工作要求上升 → 倦怠累积 → 缺勤增加”的复合路径。',
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'健康损耗路径为主',
|
||||||
|
'warning',
|
||||||
|
'当前结果更接近“工作要求上升 → 倦怠累积 → 缺勤增加”的健康损耗路径。',
|
||||||
|
)
|
||||||
|
if resources_low or resource_pressure > 0.03:
|
||||||
|
return (
|
||||||
|
'激励支撑不足路径',
|
||||||
|
'warning',
|
||||||
|
'当前资源与个人恢复能力偏弱,工作投入对缺勤风险的缓冲作用有限。',
|
||||||
|
)
|
||||||
|
if event_pressure > 0.04:
|
||||||
|
return (
|
||||||
|
'事件触发型波动',
|
||||||
|
'info',
|
||||||
|
'当前结果更容易受到请假类型、医院证明和节假日前后等事件情境直接触发。',
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'混合影响路径',
|
||||||
|
'info',
|
||||||
|
'当前结果同时受到工作要求、资源结构与事件情境的共同影响,尚不属于单一路径主导。',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_intervention_suggestions(self, data, jdr_snapshot, shap_local):
|
||||||
|
suggestions = []
|
||||||
|
|
||||||
|
demand_items = []
|
||||||
|
overtime_hours = self._safe_float(data.get('monthly_overtime_hours', 0.0))
|
||||||
|
commute_minutes = self._safe_float(data.get('commute_minutes', 0.0))
|
||||||
|
if overtime_hours >= 25 or jdr_snapshot['job_demands']['tone'] == 'danger':
|
||||||
|
demand_items.append('优先压降连续高负荷排班,尽量把月均加班控制在 20 小时以内。')
|
||||||
|
if commute_minutes >= 45:
|
||||||
|
demand_items.append('若条件允许,可通过弹性到岗、调班或就近安排缓和通勤压力。')
|
||||||
|
if self._as_flag(data.get('is_night_shift')):
|
||||||
|
demand_items.append('夜班岗位建议增加轮休和班后恢复时段,避免疲劳持续累积。')
|
||||||
|
if self._as_flag(data.get('near_holiday_flag')):
|
||||||
|
demand_items.append('节假日前后可提前做好替班和排班缓冲,减少事件性缺勤波动。')
|
||||||
|
if not demand_items:
|
||||||
|
demand_items.append('当前工作要求未明显失衡,重点保持排班稳定并持续监控波动。')
|
||||||
|
suggestions.append({'category': '减要求', 'items': self._limit_unique_items(demand_items)})
|
||||||
|
|
||||||
|
resource_items = []
|
||||||
|
if jdr_snapshot['job_resources']['tone'] in {'warning', 'danger'}:
|
||||||
|
resource_items.append('增加主管沟通、临时替班支持和班组协同,补足组织支持资源。')
|
||||||
|
if jdr_snapshot['balance']['tone'] in {'warning', 'danger'}:
|
||||||
|
resource_items.append('对高风险岗位提供更清晰的任务边界和优先级,降低角色冲突。')
|
||||||
|
if str(data.get('leave_reason_category', '')) == '子女照护':
|
||||||
|
resource_items.append('可结合弹性工时或家庭照护支持,缓解家庭事务对缺勤的放大作用。')
|
||||||
|
if not resource_items:
|
||||||
|
resource_items.append('当前资源面整体可用,建议继续维持支持性排班和沟通反馈机制。')
|
||||||
|
suggestions.append({'category': '增资源', 'items': self._limit_unique_items(resource_items)})
|
||||||
|
|
||||||
|
personal_items = []
|
||||||
|
if self._as_flag(data.get('chronic_disease_flag')) or self._as_flag(data.get('medical_certificate_flag')):
|
||||||
|
personal_items.append('结合健康监测、复诊安排和短期工作调整,降低身体不适带来的持续缺勤风险。')
|
||||||
|
if jdr_snapshot['burnout_risk']['tone'] in {'warning', 'danger'}:
|
||||||
|
personal_items.append('建议通过休息恢复、情绪支持和短周期工作调整,缓冲倦怠累积。')
|
||||||
|
if jdr_snapshot['personal_resources']['tone'] == 'danger':
|
||||||
|
personal_items.append('可通过辅导、复盘和岗位支持增强员工自我效能与心理韧性。')
|
||||||
|
if not personal_items:
|
||||||
|
personal_items.append('当前个体恢复能力整体可控,重点维持规律作息和健康管理即可。')
|
||||||
|
suggestions.append({'category': '补个人资源', 'items': self._limit_unique_items(personal_items)})
|
||||||
|
|
||||||
|
return suggestions
|
||||||
|
|
||||||
|
def _extract_dimension_scores(self, shap_local):
|
||||||
|
if not shap_local:
|
||||||
|
return {}
|
||||||
|
dimension_contribution = shap_local.get('dimension_contribution', {})
|
||||||
|
return {
|
||||||
|
key: self._safe_float(value)
|
||||||
|
for key, value in dimension_contribution.items()
|
||||||
|
if isinstance(value, (int, float))
|
||||||
|
}
|
||||||
|
|
||||||
|
def _extract_feature_effects(self, shap_local, positive=True, limit=3):
|
||||||
|
if not shap_local:
|
||||||
|
return []
|
||||||
|
features = shap_local.get('features', [])
|
||||||
|
filtered = []
|
||||||
|
for item in features:
|
||||||
|
shap_value = self._safe_float(item.get('shap_value', 0.0))
|
||||||
|
if positive and shap_value <= 0:
|
||||||
|
continue
|
||||||
|
if not positive and shap_value >= 0:
|
||||||
|
continue
|
||||||
|
filtered.append({
|
||||||
|
'name': item.get('name'),
|
||||||
|
'name_cn': item.get('name_cn') or item.get('name') or '未命名特征',
|
||||||
|
'dimension': self._dimension_label(item.get('dimension')),
|
||||||
|
'shap_value': round(shap_value, 4),
|
||||||
|
})
|
||||||
|
filtered.sort(key=lambda entry: entry['shap_value'], reverse=positive)
|
||||||
|
if not positive:
|
||||||
|
filtered.sort(key=lambda entry: abs(entry['shap_value']), reverse=True)
|
||||||
|
return filtered[:limit]
|
||||||
|
|
||||||
|
def _dimension_label(self, key):
|
||||||
|
if key in config.JDR_DIMENSIONS:
|
||||||
|
return config.JDR_DIMENSIONS[key]['name_cn']
|
||||||
|
if key == 'event_context':
|
||||||
|
return '事件上下文'
|
||||||
|
if key == 'other':
|
||||||
|
return '其他因素'
|
||||||
|
return key or '其他因素'
|
||||||
|
|
||||||
|
def _limit_unique_items(self, items, limit=3):
|
||||||
|
unique_items = []
|
||||||
|
for item in items:
|
||||||
|
if item not in unique_items:
|
||||||
|
unique_items.append(item)
|
||||||
|
return unique_items[:limit]
|
||||||
|
|
||||||
|
def _classify_job_demands(self, score):
|
||||||
|
score = self._safe_float(score)
|
||||||
|
if score >= 5.2:
|
||||||
|
return '偏高', 'danger'
|
||||||
|
if score >= 4.0:
|
||||||
|
return '中等', 'warning'
|
||||||
|
return '适中', 'success'
|
||||||
|
|
||||||
|
def _classify_resource_stock(self, score):
|
||||||
|
score = self._safe_float(score)
|
||||||
|
if score >= 3.8:
|
||||||
|
return '充足', 'success'
|
||||||
|
if score >= 3.0:
|
||||||
|
return '中等', 'warning'
|
||||||
|
return '偏低', 'danger'
|
||||||
|
|
||||||
|
def _classify_balance(self, score):
|
||||||
|
score = self._safe_float(score)
|
||||||
|
if score >= 0.8:
|
||||||
|
return '资源占优', 'success'
|
||||||
|
if score >= 0.0:
|
||||||
|
return '基本平衡', 'info'
|
||||||
|
if score >= -0.8:
|
||||||
|
return '轻度失衡', 'warning'
|
||||||
|
return '明显失衡', 'danger'
|
||||||
|
|
||||||
|
def _classify_burnout(self, score):
|
||||||
|
score = self._safe_float(score)
|
||||||
|
if score >= 2.8:
|
||||||
|
return '偏高', 'danger'
|
||||||
|
if score >= 2.0:
|
||||||
|
return '中等', 'warning'
|
||||||
|
return '可控', 'success'
|
||||||
|
|
||||||
def _inverse_transform_prediction(self, prediction):
|
def _inverse_transform_prediction(self, prediction):
|
||||||
if self.training_metadata.get('target_transform') == 'log1p':
|
if self.training_metadata.get('target_transform') == 'log1p':
|
||||||
return float(np.expm1(prediction))
|
return float(np.expm1(prediction))
|
||||||
@@ -202,13 +552,13 @@ class PredictService:
|
|||||||
|
|
||||||
def _get_default_prediction(self, data):
|
def _get_default_prediction(self, data):
|
||||||
base_hours = 3.8
|
base_hours = 3.8
|
||||||
base_hours += min(float(data.get('monthly_overtime_hours', 24)) / 20, 3.0)
|
base_hours += min(self._safe_float(data.get('monthly_overtime_hours', 24)) / 20, 3.0)
|
||||||
base_hours += min(float(data.get('commute_minutes', 40)) / 50, 2.0)
|
base_hours += min(self._safe_float(data.get('commute_minutes', 40)) / 50, 2.0)
|
||||||
base_hours += 1.6 if int(data.get('is_night_shift', 0)) == 1 else 0
|
base_hours += 1.6 if self._as_flag(data.get('is_night_shift')) else 0
|
||||||
base_hours += 1.8 if int(data.get('chronic_disease_flag', 0)) == 1 else 0
|
base_hours += 1.8 if self._as_flag(data.get('chronic_disease_flag')) else 0
|
||||||
base_hours += 0.9 if int(data.get('near_holiday_flag', 0)) == 1 else 0
|
base_hours += 0.9 if self._as_flag(data.get('near_holiday_flag')) else 0
|
||||||
base_hours += 0.8 if int(data.get('medical_certificate_flag', 0)) == 1 else 0
|
base_hours += 0.8 if self._as_flag(data.get('medical_certificate_flag')) else 0
|
||||||
base_hours += 0.5 * int(data.get('children_count', 0))
|
base_hours += 0.5 * int(self._safe_float(data.get('children_count', 0)))
|
||||||
if data.get('leave_type') in ['病假', '工伤假', '婚假', '丧假']:
|
if data.get('leave_type') in ['病假', '工伤假', '婚假', '丧假']:
|
||||||
base_hours += 2.5
|
base_hours += 2.5
|
||||||
if data.get('stress_level') == '高':
|
if data.get('stress_level') == '高':
|
||||||
@@ -227,7 +577,6 @@ class PredictService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _get_risk_probability(self, features, model_type):
|
def _get_risk_probability(self, features, model_type):
|
||||||
"""获取分类器预测的风险概率"""
|
|
||||||
classifier = self.classifiers.get(model_type)
|
classifier = self.classifiers.get(model_type)
|
||||||
if classifier is None:
|
if classifier is None:
|
||||||
classifier = self.classifiers.get('random_forest')
|
classifier = self.classifiers.get('random_forest')
|
||||||
@@ -246,7 +595,6 @@ class PredictService:
|
|||||||
return {'low': 0.0, 'medium': 1.0, 'high': 0.0}
|
return {'low': 0.0, 'medium': 1.0, 'high': 0.0}
|
||||||
|
|
||||||
def predict_risk_classification(self, data, model_type=None):
|
def predict_risk_classification(self, data, model_type=None):
|
||||||
"""使用分类模型直接预测风险等级"""
|
|
||||||
self._ensure_models_loaded()
|
self._ensure_models_loaded()
|
||||||
model_type = model_type or self.default_model
|
model_type = model_type or self.default_model
|
||||||
classifier = self.classifiers.get(model_type)
|
classifier = self.classifiers.get(model_type)
|
||||||
@@ -293,5 +641,17 @@ class PredictService:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _safe_float(self, value, default=0.0):
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
def _as_flag(self, value):
|
||||||
|
try:
|
||||||
|
return int(value) == 1
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
predict_service = PredictService()
|
predict_service = PredictService()
|
||||||
|
|||||||
@@ -28,13 +28,58 @@ class SHAPService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_global_importance(self, model_type='random_forest'):
|
def _save_cache(self, model_type, payload):
|
||||||
cache = self._load_cache(model_type)
|
os.makedirs(config.SHAP_CACHE_DIR, exist_ok=True)
|
||||||
if not cache:
|
cache_path = self._get_cache_path(model_type)
|
||||||
|
with open(cache_path, 'w', encoding='utf-8') as fp:
|
||||||
|
json.dump(payload, fp, ensure_ascii=False)
|
||||||
|
|
||||||
|
def _build_cache_payload(self, model_type):
|
||||||
|
self._ensure_analyzer()
|
||||||
|
global_data = self._analyzer.global_shap_values(model_type)
|
||||||
|
if global_data.get('error'):
|
||||||
|
return {'error': global_data['error']}
|
||||||
|
|
||||||
|
top_features = [item['name'] for item in global_data.get('top_features', [])[:15]]
|
||||||
|
dependence = {}
|
||||||
|
for feature_name in top_features:
|
||||||
|
data = self._analyzer.shap_dependence(feature_name, model_type)
|
||||||
|
if not data.get('error'):
|
||||||
|
dependence[feature_name] = data
|
||||||
|
|
||||||
|
interaction = self._analyzer.shap_interaction(model_type, top_n=10)
|
||||||
|
if interaction.get('error'):
|
||||||
|
return {'error': interaction['error']}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'error': f'SHAP cache not found for {model_type}. '
|
'model_type': model_type,
|
||||||
f'Run backend/core/generate_shap_cache.py first.'
|
'global': global_data,
|
||||||
|
'dependence': dependence,
|
||||||
|
'interaction': interaction,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _ensure_cache(self, model_type):
|
||||||
|
cache = self._load_cache(model_type)
|
||||||
|
if cache:
|
||||||
|
return cache
|
||||||
|
|
||||||
|
payload = self._build_cache_payload(model_type)
|
||||||
|
if payload.get('error'):
|
||||||
|
return {
|
||||||
|
'error': f'{model_type} 的贡献解释数据暂时不可用:{payload["error"]}'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._save_cache(model_type, payload)
|
||||||
|
except Exception:
|
||||||
|
# 缓存写入失败时至少保证当前请求可继续返回结果。
|
||||||
|
pass
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def get_global_importance(self, model_type='random_forest'):
|
||||||
|
cache = self._ensure_cache(model_type)
|
||||||
|
if cache.get('error'):
|
||||||
|
return cache
|
||||||
return cache.get('global', {'error': f'Invalid SHAP cache for {model_type}'})
|
return cache.get('global', {'error': f'Invalid SHAP cache for {model_type}'})
|
||||||
|
|
||||||
def get_local_explanation(self, data, model_type='random_forest'):
|
def get_local_explanation(self, data, model_type='random_forest'):
|
||||||
@@ -42,12 +87,9 @@ class SHAPService:
|
|||||||
return self._analyzer.local_shap_values(data, model_type)
|
return self._analyzer.local_shap_values(data, model_type)
|
||||||
|
|
||||||
def get_interactions(self, model_type='random_forest', top_n=10):
|
def get_interactions(self, model_type='random_forest', top_n=10):
|
||||||
cache = self._load_cache(model_type)
|
cache = self._ensure_cache(model_type)
|
||||||
if not cache:
|
if cache.get('error'):
|
||||||
return {
|
return cache
|
||||||
'error': f'SHAP cache not found for {model_type}. '
|
|
||||||
f'Run backend/core/generate_shap_cache.py first.'
|
|
||||||
}
|
|
||||||
data = cache.get('interaction')
|
data = cache.get('interaction')
|
||||||
if not data:
|
if not data:
|
||||||
return {'error': f'Interaction cache missing for {model_type}'}
|
return {'error': f'Interaction cache missing for {model_type}'}
|
||||||
@@ -58,17 +100,26 @@ class SHAPService:
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
def get_dependence(self, feature_name, model_type='random_forest'):
|
def get_dependence(self, feature_name, model_type='random_forest'):
|
||||||
cache = self._load_cache(model_type)
|
cache = self._ensure_cache(model_type)
|
||||||
if not cache:
|
if cache.get('error'):
|
||||||
return {
|
return cache
|
||||||
'error': f'SHAP cache not found for {model_type}. '
|
|
||||||
f'Run backend/core/generate_shap_cache.py first.'
|
|
||||||
}
|
|
||||||
dependence_map = cache.get('dependence', {})
|
dependence_map = cache.get('dependence', {})
|
||||||
data = dependence_map.get(feature_name)
|
data = dependence_map.get(feature_name)
|
||||||
if data:
|
if data:
|
||||||
return data
|
return data
|
||||||
return {'error': f'Dependence cache missing for feature {feature_name}'}
|
|
||||||
|
self._ensure_analyzer()
|
||||||
|
data = self._analyzer.shap_dependence(feature_name, model_type)
|
||||||
|
if data.get('error'):
|
||||||
|
return {'error': f'特征 {feature_name} 的依赖解释不可用:{data["error"]}'}
|
||||||
|
|
||||||
|
dependence_map[feature_name] = data
|
||||||
|
cache['dependence'] = dependence_map
|
||||||
|
try:
|
||||||
|
self._save_cache(model_type, cache)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
shap_service = SHAPService()
|
shap_service = SHAPService()
|
||||||
|
|||||||
120
backend/tests/test_clustering_naming.py
Normal file
120
backend/tests/test_clustering_naming.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def load_clustering_module():
|
||||||
|
module_path = Path(r'D:\forsetsystem\backend\core\clustering.py')
|
||||||
|
|
||||||
|
fake_config = types.SimpleNamespace(
|
||||||
|
RANDOM_STATE=42,
|
||||||
|
TARGET_COLUMN='缺勤时长(小时)',
|
||||||
|
EMPLOYEE_ID_COLUMN='员工工号',
|
||||||
|
FEATURE_NAME_CN={
|
||||||
|
'月均加班时长': '月均加班时长',
|
||||||
|
'缺勤时长(小时)': '缺勤时长(小时)',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
fake_preprocessing = types.ModuleType('core.preprocessing')
|
||||||
|
fake_preprocessing.get_clean_data = lambda: None
|
||||||
|
fake_sklearn = types.ModuleType('sklearn')
|
||||||
|
fake_sklearn_cluster = types.ModuleType('sklearn.cluster')
|
||||||
|
fake_sklearn_preprocessing = types.ModuleType('sklearn.preprocessing')
|
||||||
|
|
||||||
|
class DummyKMeans:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.cluster_centers_ = None
|
||||||
|
|
||||||
|
def fit_predict(self, data):
|
||||||
|
self.cluster_centers_ = np.asarray(data, dtype=float)
|
||||||
|
return np.zeros(len(data), dtype=int)
|
||||||
|
|
||||||
|
class DummyMinMaxScaler:
|
||||||
|
def fit_transform(self, data):
|
||||||
|
return np.asarray(data, dtype=float)
|
||||||
|
|
||||||
|
def inverse_transform(self, data):
|
||||||
|
return np.asarray(data, dtype=float)
|
||||||
|
|
||||||
|
fake_sklearn_cluster.KMeans = DummyKMeans
|
||||||
|
fake_sklearn_preprocessing.MinMaxScaler = DummyMinMaxScaler
|
||||||
|
|
||||||
|
sys.modules['config'] = fake_config
|
||||||
|
sys.modules['core.preprocessing'] = fake_preprocessing
|
||||||
|
sys.modules['sklearn'] = fake_sklearn
|
||||||
|
sys.modules['sklearn.cluster'] = fake_sklearn_cluster
|
||||||
|
sys.modules['sklearn.preprocessing'] = fake_sklearn_preprocessing
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location('test_clustering_module', module_path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class ClusterNamingTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
module = load_clustering_module()
|
||||||
|
cls.analyzer = module.KMeansAnalyzer()
|
||||||
|
|
||||||
|
def test_generate_cluster_names_avoids_generic_group_names(self):
|
||||||
|
centers = np.array([
|
||||||
|
[41, 11, 18, 28, 22.5, 4.2],
|
||||||
|
[30, 3, 22, 33, 23.0, 5.8],
|
||||||
|
[36, 7, 36, 52, 24.0, 8.6],
|
||||||
|
[38, 6, 24, 31, 27.2, 8.1],
|
||||||
|
], dtype=float)
|
||||||
|
|
||||||
|
names = self.analyzer._generate_cluster_names(centers)
|
||||||
|
|
||||||
|
self.assertEqual(len(names), 4)
|
||||||
|
for name in names.values():
|
||||||
|
self.assertNotIn('群体', name)
|
||||||
|
|
||||||
|
def test_generate_cluster_names_returns_business_labels(self):
|
||||||
|
centers = np.array([
|
||||||
|
[42, 10, 16, 26, 22.0, 4.1],
|
||||||
|
[29, 2, 20, 30, 22.8, 5.6],
|
||||||
|
[35, 6, 34, 50, 24.1, 8.8],
|
||||||
|
[37, 7, 23, 29, 27.5, 8.0],
|
||||||
|
], dtype=float)
|
||||||
|
|
||||||
|
names = self.analyzer._generate_cluster_names(centers)
|
||||||
|
|
||||||
|
self.assertIn('稳定成熟型', names.values())
|
||||||
|
self.assertIn('新锐成长型', names.values())
|
||||||
|
self.assertIn('压力奔波型', names.values())
|
||||||
|
self.assertIn('健康关注型', names.values())
|
||||||
|
|
||||||
|
def test_duplicate_names_receive_natural_suffixes(self):
|
||||||
|
centers = np.array([
|
||||||
|
[44, 12, 18, 29, 22.2, 4.0],
|
||||||
|
[39, 9, 20, 34, 23.1, 5.3],
|
||||||
|
[32, 4, 31, 46, 24.8, 7.2],
|
||||||
|
], dtype=float)
|
||||||
|
|
||||||
|
names = self.analyzer._deduplicate_cluster_names(
|
||||||
|
{0: '稳定成熟型', 1: '稳定成熟型', 2: '负荷承压型'},
|
||||||
|
centers,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual({names[0], names[1]}, {'稳定成熟型-资深组', '稳定成熟型-成熟组'})
|
||||||
|
self.assertEqual(names[2], '负荷承压型')
|
||||||
|
|
||||||
|
def test_description_reflects_center_traits(self):
|
||||||
|
description = self.analyzer._generate_description(
|
||||||
|
'压力奔波型',
|
||||||
|
np.array([34, 5, 36, 52, 24.0, 8.3], dtype=float),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('加班负荷偏高', description)
|
||||||
|
self.assertIn('通勤压力偏高', description)
|
||||||
|
self.assertIn('缺勤时长偏高', description)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
155
backend/tests/test_predict_explanation.py
Normal file
155
backend/tests/test_predict_explanation.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def load_predict_module():
|
||||||
|
module_path = Path(r'D:\forsetsystem\backend\services\predict_service.py')
|
||||||
|
|
||||||
|
fake_config = types.SimpleNamespace(
|
||||||
|
MODELS_DIR='',
|
||||||
|
SCALER_PATH='',
|
||||||
|
JDR_DIMENSIONS={
|
||||||
|
'job_demands': {'name_cn': '工作要求'},
|
||||||
|
'job_resources': {'name_cn': '工作资源'},
|
||||||
|
'personal_resources': {'name_cn': '个人资源'},
|
||||||
|
'mediators': {'name_cn': '中介变量'},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
fake_deep_learning = types.ModuleType('core.deep_learning_model')
|
||||||
|
fake_deep_learning.load_lstm_mlp_bundle = lambda path: None
|
||||||
|
fake_deep_learning.predict_lstm_mlp = lambda model, data: 0.0
|
||||||
|
|
||||||
|
fake_model_features = types.ModuleType('core.model_features')
|
||||||
|
fake_model_features.align_feature_frame = lambda frame, names: frame
|
||||||
|
fake_model_features.apply_label_encoders = lambda frame, encoders: frame
|
||||||
|
fake_model_features.build_prediction_dataframe = lambda data: data
|
||||||
|
fake_model_features.engineer_features = lambda frame: frame
|
||||||
|
fake_model_features.to_float_array = lambda frame: frame
|
||||||
|
|
||||||
|
sys.modules['config'] = fake_config
|
||||||
|
sys.modules['core.deep_learning_model'] = fake_deep_learning
|
||||||
|
sys.modules['core.model_features'] = fake_model_features
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location('test_predict_service_module', module_path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class PredictExplanationTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
module = load_predict_module()
|
||||||
|
cls.service = module.PredictService()
|
||||||
|
|
||||||
|
def test_build_jdr_snapshot_marks_high_demands_and_low_resources(self):
|
||||||
|
snapshot = self.service._build_jdr_snapshot({
|
||||||
|
'工作要求指数': 5.8,
|
||||||
|
'工作资源指数': 2.7,
|
||||||
|
'个人资源指数': 2.8,
|
||||||
|
'JD-R平衡度': -1.1,
|
||||||
|
'倦怠风险指数': 3.1,
|
||||||
|
'工作投入指数': 2.9,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(snapshot['job_demands']['status'], '偏高')
|
||||||
|
self.assertEqual(snapshot['job_resources']['status'], '偏低')
|
||||||
|
self.assertEqual(snapshot['balance']['status'], '明显失衡')
|
||||||
|
self.assertEqual(snapshot['burnout_risk']['status'], '偏高')
|
||||||
|
|
||||||
|
def test_mechanism_summary_prefers_health_impairment_path(self):
|
||||||
|
snapshot = self.service._build_jdr_snapshot({
|
||||||
|
'工作要求指数': 5.6,
|
||||||
|
'工作资源指数': 2.9,
|
||||||
|
'个人资源指数': 2.8,
|
||||||
|
'JD-R平衡度': -0.9,
|
||||||
|
'倦怠风险指数': 3.0,
|
||||||
|
'工作投入指数': 2.9,
|
||||||
|
})
|
||||||
|
shap_local = {
|
||||||
|
'dimension_contribution': {
|
||||||
|
'工作要求': 0.32,
|
||||||
|
'中介变量': 0.18,
|
||||||
|
'事件上下文': 0.11,
|
||||||
|
'工作资源': -0.07,
|
||||||
|
},
|
||||||
|
'features': [
|
||||||
|
{'name': 'monthly_overtime_hours', 'name_cn': '月均加班时长', 'dimension': 'job_demands', 'shap_value': 0.18},
|
||||||
|
{'name': 'commute_minutes', 'name_cn': '通勤时长', 'dimension': 'job_demands', 'shap_value': 0.12},
|
||||||
|
{'name': 'medical_certificate_flag', 'name_cn': '医院证明', 'dimension': 'event_context', 'shap_value': 0.08},
|
||||||
|
{'name': 'coworker_support', 'name_cn': '同事支持', 'dimension': 'job_resources', 'shap_value': -0.05},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
result = {'predicted_hours': 9.4, 'risk_label': '高风险'}
|
||||||
|
data = {
|
||||||
|
'monthly_overtime_hours': 38,
|
||||||
|
'commute_minutes': 62,
|
||||||
|
'is_night_shift': 1,
|
||||||
|
'medical_certificate_flag': 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = self.service._build_mechanism_summary(result, data, snapshot, shap_local)
|
||||||
|
|
||||||
|
self.assertIn('健康损耗', summary['pathway_label'])
|
||||||
|
self.assertIn('月均加班时长', summary['mechanism'])
|
||||||
|
self.assertTrue(summary['scenario_hint'])
|
||||||
|
|
||||||
|
def test_intervention_suggestions_cover_resource_and_personal_support(self):
|
||||||
|
snapshot = self.service._build_jdr_snapshot({
|
||||||
|
'工作要求指数': 4.4,
|
||||||
|
'工作资源指数': 2.7,
|
||||||
|
'个人资源指数': 2.6,
|
||||||
|
'JD-R平衡度': -0.7,
|
||||||
|
'倦怠风险指数': 2.9,
|
||||||
|
'工作投入指数': 2.8,
|
||||||
|
})
|
||||||
|
suggestions = self.service._build_intervention_suggestions(
|
||||||
|
{
|
||||||
|
'monthly_overtime_hours': 18,
|
||||||
|
'commute_minutes': 28,
|
||||||
|
'chronic_disease_flag': 1,
|
||||||
|
'medical_certificate_flag': 1,
|
||||||
|
'leave_reason_category': '子女照护',
|
||||||
|
},
|
||||||
|
snapshot,
|
||||||
|
shap_local=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
category_map = {item['category']: item['items'] for item in suggestions}
|
||||||
|
self.assertIn('增资源', category_map)
|
||||||
|
self.assertIn('补个人资源', category_map)
|
||||||
|
self.assertTrue(any('支持' in item or '弹性' in item for item in category_map['增资源']))
|
||||||
|
self.assertTrue(any('健康' in item or '倦怠' in item for item in category_map['补个人资源']))
|
||||||
|
|
||||||
|
def test_buffer_text_mentions_protective_factors(self):
|
||||||
|
snapshot = self.service._build_jdr_snapshot({
|
||||||
|
'工作要求指数': 3.9,
|
||||||
|
'工作资源指数': 4.2,
|
||||||
|
'个人资源指数': 4.0,
|
||||||
|
'JD-R平衡度': 0.9,
|
||||||
|
'倦怠风险指数': 1.8,
|
||||||
|
'工作投入指数': 4.1,
|
||||||
|
})
|
||||||
|
shap_local = {
|
||||||
|
'dimension_contribution': {
|
||||||
|
'工作要求': 0.08,
|
||||||
|
'工作资源': -0.12,
|
||||||
|
'个人资源': -0.09,
|
||||||
|
},
|
||||||
|
'features': [
|
||||||
|
{'name': 'supervisor_support', 'name_cn': '上级支持', 'dimension': 'job_resources', 'shap_value': -0.07},
|
||||||
|
{'name': 'self_efficacy', 'name_cn': '自我效能感', 'dimension': 'personal_resources', 'shap_value': -0.05},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = self.service._build_mechanism_summary({'predicted_hours': 5.3, 'risk_label': '中风险'}, {}, snapshot, shap_local)
|
||||||
|
|
||||||
|
self.assertIn('缓冲作用', summary['buffer_text'])
|
||||||
|
self.assertTrue(summary['protective_factors'])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -2,10 +2,9 @@
|
|||||||
<div class="shell" :class="{ 'shell-collapsed': isSidebarCollapsed }">
|
<div class="shell" :class="{ 'shell-collapsed': isSidebarCollapsed }">
|
||||||
<aside class="shell-sidebar">
|
<aside class="shell-sidebar">
|
||||||
<div class="brand-block">
|
<div class="brand-block">
|
||||||
<div class="brand-mark">HR</div>
|
<div class="brand-mark"></div>
|
||||||
<div v-if="!isSidebarCollapsed">
|
<div v-if="!isSidebarCollapsed">
|
||||||
<div class="brand-title">企业缺勤分析台</div>
|
<div class="brand-title">企业缺勤分析台</div>
|
||||||
<div class="brand-subtitle">Human Resource Insight Console</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -30,14 +29,14 @@
|
|||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="/jdr-analysis">
|
<el-menu-item index="/jdr-analysis">
|
||||||
<el-icon class="nav-icon"><Reading /></el-icon>
|
<el-icon class="nav-icon"><Reading /></el-icon>
|
||||||
<span class="nav-label">JD-R分析</span>
|
<span class="nav-label">理论分析</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!isSidebarCollapsed" class="sidebar-note">
|
<div v-if="!isSidebarCollapsed" class="sidebar-note">
|
||||||
<div class="sidebar-label">系统摘要</div>
|
<div class="sidebar-label">系统摘要</div>
|
||||||
<p>面向企业管理场景的缺勤趋势、风险预测与群体画像展示。</p>
|
<p>缺勤趋势、风险预测与群体画像。</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -56,8 +55,6 @@
|
|||||||
<el-button class="theme-toggle" @click="toggleTheme">
|
<el-button class="theme-toggle" @click="toggleTheme">
|
||||||
{{ isDarkMode ? '浅色模式' : '深色模式' }}
|
{{ isDarkMode ? '浅色模式' : '深色模式' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<span class="topbar-badge">企业健康运营分析</span>
|
|
||||||
<span class="topbar-badge topbar-badge-accent">可视化决策界面</span>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -81,23 +78,23 @@ const isDarkMode = ref(false)
|
|||||||
const metaMap = {
|
const metaMap = {
|
||||||
'/dashboard': {
|
'/dashboard': {
|
||||||
title: '数据概览',
|
title: '数据概览',
|
||||||
subtitle: '从企业缺勤事件的总量、时序与结构分布切入,建立整体认知。'
|
subtitle: '缺勤事件总量、趋势与结构分布'
|
||||||
},
|
},
|
||||||
'/analysis': {
|
'/analysis': {
|
||||||
title: '影响因素',
|
title: '影响因素',
|
||||||
subtitle: '观察模型最关注的驱动因素,辅助解释缺勤风险的来源。'
|
subtitle: '关键驱动因素与群体差异'
|
||||||
},
|
},
|
||||||
'/prediction': {
|
'/prediction': {
|
||||||
title: '缺勤预测',
|
title: '缺勤预测',
|
||||||
subtitle: '围绕最核心的业务信号输入,快速获得缺勤时长与风险等级。'
|
subtitle: '缺勤时长、风险等级与模型对比'
|
||||||
},
|
},
|
||||||
'/clustering': {
|
'/clustering': {
|
||||||
title: '员工画像',
|
title: '员工画像',
|
||||||
subtitle: '通过聚类划分典型群体,为答辩演示提供更直观的人群视角。'
|
subtitle: '典型群体划分与画像分析'
|
||||||
},
|
},
|
||||||
'/jdr-analysis': {
|
'/jdr-analysis': {
|
||||||
title: 'JD-R理论分析',
|
title: '理论分析',
|
||||||
subtitle: '基于工作要求-资源理论的可解释分析,揭示缺勤的心理学驱动因素。'
|
subtitle: '工作要求、资源支持与缺勤风险'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,8 +161,28 @@ watch(isDarkMode, value => {
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: linear-gradient(135deg, #fef3c7, #fdba74);
|
background: linear-gradient(135deg, #fef3c7, #fdba74);
|
||||||
color: #7c2d12;
|
color: #7c2d12;
|
||||||
font-weight: 800;
|
position: relative;
|
||||||
letter-spacing: 0.08em;
|
}
|
||||||
|
|
||||||
|
.brand-mark::before,
|
||||||
|
.brand-mark::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(124, 45, 18, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark::before {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark::after {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
right: 11px;
|
||||||
|
bottom: 11px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-title {
|
.brand-title {
|
||||||
@@ -174,12 +191,6 @@ watch(isDarkMode, value => {
|
|||||||
color: var(--sidebar-text);
|
color: var(--sidebar-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-subtitle {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--sidebar-text-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-panel,
|
.sidebar-panel,
|
||||||
.sidebar-note {
|
.sidebar-note {
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
@@ -296,19 +307,6 @@ watch(isDarkMode, value => {
|
|||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-badge {
|
|
||||||
padding: 9px 14px;
|
|
||||||
border: 1px solid var(--line-soft);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--surface);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--brand-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar-badge-accent {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const routes = [
|
|||||||
path: '/jdr-analysis',
|
path: '/jdr-analysis',
|
||||||
name: 'JDRAnalysis',
|
name: 'JDRAnalysis',
|
||||||
component: () => import('@/views/JDRAnalysis.vue'),
|
component: () => import('@/views/JDRAnalysis.vue'),
|
||||||
meta: { title: 'JD-R理论分析' }
|
meta: { title: '理论分析' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -104,14 +104,6 @@ a {
|
|||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-eyebrow {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
letter-spacing: 0.22em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
opacity: 0.72;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
@@ -168,21 +160,6 @@ a {
|
|||||||
height: 300px;
|
height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.soft-tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 7px 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--brand-strong);
|
|
||||||
background: rgba(15, 118, 110, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme='dark'] .soft-tag {
|
|
||||||
background: rgba(52, 211, 153, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.soft-grid {
|
.soft-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-shell">
|
<div class="page-shell">
|
||||||
<section class="page-hero cluster-hero">
|
<section class="page-hero cluster-hero">
|
||||||
<div class="page-eyebrow">Clustering</div>
|
|
||||||
<h1 class="page-title">员工画像与群体切片</h1>
|
<h1 class="page-title">员工画像与群体切片</h1>
|
||||||
<p class="page-description">
|
<p class="page-description">
|
||||||
将员工划分为不同缺勤画像群体,通过雷达图和散点图形成直观的人群对比展示。
|
基于缺勤行为、工作压力和基础属性划分典型员工群体。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -12,7 +11,7 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="section-title">群体雷达画像</h3>
|
<h3 class="section-title">群体雷达画像</h3>
|
||||||
<p class="section-caption">以年龄、司龄、加班、通勤、BMI 和缺勤水平构建群体轮廓。</p>
|
<p class="section-caption">年龄、司龄、加班、通勤、BMI 与缺勤水平</p>
|
||||||
</div>
|
</div>
|
||||||
<el-select v-model="nClusters" @change="loadData" class="cluster-select">
|
<el-select v-model="nClusters" @change="loadData" class="cluster-select">
|
||||||
<el-option :label="2" :value="2" />
|
<el-option :label="2" :value="2" />
|
||||||
@@ -29,9 +28,8 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="section-title">聚类结果</h3>
|
<h3 class="section-title">聚类结果</h3>
|
||||||
<p class="section-caption">便于答辩时逐个介绍群体特征。</p>
|
<p class="section-caption">群体规模与主要特征</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="soft-tag">Profiles</span>
|
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="clusterData" stripe class="cluster-table">
|
<el-table :data="clusterData" stripe class="cluster-table">
|
||||||
<el-table-column prop="name" label="群体名称" min-width="120" />
|
<el-table-column prop="name" label="群体名称" min-width="120" />
|
||||||
@@ -39,7 +37,7 @@
|
|||||||
<el-table-column prop="percentage" label="占比(%)" width="90">
|
<el-table-column prop="percentage" label="占比(%)" width="90">
|
||||||
<template #default="{ row }">{{ row.percentage }}%</template>
|
<template #default="{ row }">{{ row.percentage }}%</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="description" label="说明" min-width="180" />
|
<el-table-column prop="description" label="群体特征" min-width="180" />
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -48,9 +46,8 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="section-title">加班与缺勤散点图</h3>
|
<h3 class="section-title">加班与缺勤散点图</h3>
|
||||||
<p class="section-caption">展示各聚类在加班强度与缺勤水平上的位置差异。</p>
|
<p class="section-caption">加班强度与缺勤水平分布</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="soft-tag">Scatter</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div ref="scatterChartRef" class="chart-frame"></div>
|
<div ref="scatterChartRef" class="chart-frame"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -60,7 +57,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import request from '@/api/request'
|
import request from '@/api/request'
|
||||||
|
|
||||||
@@ -68,19 +65,44 @@ const radarChartRef = ref(null)
|
|||||||
const scatterChartRef = ref(null)
|
const scatterChartRef = ref(null)
|
||||||
const nClusters = ref(3)
|
const nClusters = ref(3)
|
||||||
const clusterData = ref([])
|
const clusterData = ref([])
|
||||||
|
const loadVersion = ref(0)
|
||||||
|
let radarChart = null
|
||||||
|
let scatterChart = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
radarChart?.dispose()
|
||||||
|
scatterChart?.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
await Promise.all([initRadarChart(), initScatterChart(), loadClusterResult()])
|
const currentClusters = nClusters.value
|
||||||
|
const version = ++loadVersion.value
|
||||||
|
const [profile, scatter, result] = await Promise.all([
|
||||||
|
request.get(`/cluster/profile?n_clusters=${currentClusters}`),
|
||||||
|
request.get(`/cluster/scatter?n_clusters=${currentClusters}`),
|
||||||
|
request.get(`/cluster/result?n_clusters=${currentClusters}`)
|
||||||
|
])
|
||||||
|
|
||||||
|
if (version !== loadVersion.value || currentClusters !== nClusters.value) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initRadarChart() {
|
renderRadarChart(profile)
|
||||||
const chart = echarts.init(radarChartRef.value)
|
renderScatterChart(scatter)
|
||||||
const data = await request.get(`/cluster/profile?n_clusters=${nClusters.value}`)
|
clusterData.value = result.clusters
|
||||||
chart.setOption({
|
}
|
||||||
|
|
||||||
|
function renderRadarChart(data) {
|
||||||
|
if (!radarChartRef.value) return
|
||||||
|
if (!radarChart) {
|
||||||
|
radarChart = echarts.init(radarChartRef.value)
|
||||||
|
}
|
||||||
|
radarChart.clear()
|
||||||
|
radarChart.setOption({
|
||||||
tooltip: {},
|
tooltip: {},
|
||||||
legend: { top: 6, data: data.clusters.map(item => item.name) },
|
legend: { top: 6, data: data.clusters.map(item => item.name) },
|
||||||
radar: { indicator: data.dimensions.map(name => ({ name, max: 1 })), radius: '62%' },
|
radar: { indicator: data.dimensions.map(name => ({ name, max: 1 })), radius: '62%' },
|
||||||
@@ -88,15 +110,18 @@ async function initRadarChart() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initScatterChart() {
|
function renderScatterChart(data) {
|
||||||
const chart = echarts.init(scatterChartRef.value)
|
if (!scatterChartRef.value) return
|
||||||
const data = await request.get(`/cluster/scatter?n_clusters=${nClusters.value}`)
|
if (!scatterChart) {
|
||||||
|
scatterChart = echarts.init(scatterChartRef.value)
|
||||||
|
}
|
||||||
|
scatterChart.clear()
|
||||||
const grouped = {}
|
const grouped = {}
|
||||||
data.points.forEach(point => {
|
data.points.forEach(point => {
|
||||||
if (!grouped[point.cluster_id]) grouped[point.cluster_id] = []
|
if (!grouped[point.cluster_id]) grouped[point.cluster_id] = []
|
||||||
grouped[point.cluster_id].push([point.x, point.y])
|
grouped[point.cluster_id].push([point.x, point.y])
|
||||||
})
|
})
|
||||||
chart.setOption({
|
scatterChart.setOption({
|
||||||
tooltip: { trigger: 'item' },
|
tooltip: { trigger: 'item' },
|
||||||
grid: { left: 36, right: 18, top: 20, bottom: 36, containLabel: true },
|
grid: { left: 36, right: 18, top: 20, bottom: 36, containLabel: true },
|
||||||
xAxis: { name: data.x_axis_name, splitLine: { lineStyle: { color: '#E5EBF2' } } },
|
xAxis: { name: data.x_axis_name, splitLine: { lineStyle: { color: '#E5EBF2' } } },
|
||||||
@@ -109,11 +134,6 @@ async function initScatterChart() {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadClusterResult() {
|
|
||||||
const data = await request.get(`/cluster/result?n_clusters=${nClusters.value}`)
|
|
||||||
clusterData.value = data.clusters
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-shell">
|
<div class="page-shell">
|
||||||
<section class="page-hero">
|
<section class="page-hero">
|
||||||
<div class="page-eyebrow">Overview</div>
|
|
||||||
<h1 class="page-title">企业缺勤全景概览</h1>
|
<h1 class="page-title">企业缺勤全景概览</h1>
|
||||||
<p class="page-description">
|
<p class="page-description">
|
||||||
通过总量、时序、结构分布三个层面快速识别缺勤风险的整体轮廓,适合作为答辩时的第一屏总览。
|
汇总缺勤总量、趋势变化与结构分布。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -25,9 +24,8 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="section-title">月度缺勤事件趋势</h3>
|
<h3 class="section-title">月度缺勤事件趋势</h3>
|
||||||
<p class="section-caption">观察不同月份的事件量与时长波动。</p>
|
<p class="section-caption">月度事件量与时长波动</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="soft-tag">Trend</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div ref="trendChartRef" class="chart-frame"></div>
|
<div ref="trendChartRef" class="chart-frame"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -37,9 +35,8 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="section-title">星期分布</h3>
|
<h3 class="section-title">星期分布</h3>
|
||||||
<p class="section-caption">识别工作周内的缺勤集中区间。</p>
|
<p class="section-caption">工作周缺勤分布</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="soft-tag">Weekday</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div ref="weekdayChartRef" class="chart-frame"></div>
|
<div ref="weekdayChartRef" class="chart-frame"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -52,9 +49,8 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="section-title">请假原因大类分布</h3>
|
<h3 class="section-title">请假原因大类分布</h3>
|
||||||
<p class="section-caption">呈现引发缺勤的主要业务原因结构。</p>
|
<p class="section-caption">主要请假原因结构</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="soft-tag">Reason Mix</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div ref="reasonChartRef" class="chart-frame"></div>
|
<div ref="reasonChartRef" class="chart-frame"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -64,9 +60,8 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="section-title">季节影响分布</h3>
|
<h3 class="section-title">季节影响分布</h3>
|
||||||
<p class="section-caption">展示季节变化与缺勤总量之间的关系。</p>
|
<p class="section-caption">季节与缺勤总量</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="soft-tag">Season</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div ref="seasonChartRef" class="chart-frame"></div>
|
<div ref="seasonChartRef" class="chart-frame"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-shell">
|
<div class="page-shell">
|
||||||
<section class="page-hero analysis-hero">
|
<section class="page-hero analysis-hero">
|
||||||
<div class="page-eyebrow">Analysis</div>
|
|
||||||
<h1 class="page-title">缺勤驱动因素洞察</h1>
|
<h1 class="page-title">缺勤驱动因素洞察</h1>
|
||||||
<p class="page-description">
|
<p class="page-description">
|
||||||
将模型特征重要性、变量相关关系与群体差异放在同一界面展示,形成更完整的解释链路。
|
汇总关键变量、相关关系与群体差异,定位缺勤风险的主要来源。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -12,9 +11,8 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="section-title">缺勤影响因素排序</h3>
|
<h3 class="section-title">缺勤影响因素排序</h3>
|
||||||
<p class="section-caption">用于展示模型最关注的驱动信号及其主次关系。</p>
|
<p class="section-caption">模型特征重要性</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="soft-tag">Importance</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div ref="importanceChartRef" class="chart-frame"></div>
|
<div ref="importanceChartRef" class="chart-frame"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -25,9 +23,8 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="section-title">核心特征相关性</h3>
|
<h3 class="section-title">核心特征相关性</h3>
|
||||||
<p class="section-caption">帮助说明关键指标之间的联动关系。</p>
|
<p class="section-caption">关键指标联动关系</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="soft-tag">Correlation</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div ref="correlationChartRef" class="chart-frame"></div>
|
<div ref="correlationChartRef" class="chart-frame"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -37,7 +34,7 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="section-title">群体对比分析</h3>
|
<h3 class="section-title">群体对比分析</h3>
|
||||||
<p class="section-caption">从行业、排班和健康等维度比较平均缺勤时长。</p>
|
<p class="section-caption">不同维度下的平均缺勤时长</p>
|
||||||
</div>
|
</div>
|
||||||
<el-select v-model="dimension" @change="loadComparison" class="dimension-select">
|
<el-select v-model="dimension" @change="loadComparison" class="dimension-select">
|
||||||
<el-option label="所属行业" value="industry" />
|
<el-option label="所属行业" value="industry" />
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-shell jdr-page">
|
<div class="page-shell jdr-page">
|
||||||
<section class="page-hero jdr-hero">
|
<section class="page-hero jdr-hero">
|
||||||
<div class="page-eyebrow">JD-R Theory</div>
|
<h1 class="page-title">JD-R 理论分析</h1>
|
||||||
<h1 class="page-title">JD-R 理论驱动的可解释分析</h1>
|
|
||||||
<p class="page-description">
|
<p class="page-description">
|
||||||
基于工作要求-资源模型,从心理学理论视角解析员工缺勤的深层驱动因素,提供可解释的干预建议。
|
基于工作要求-资源模型,分析工作压力、资源支持与缺勤风险之间的关系。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<el-tabs v-model="activeTab" type="border-card" class="jdr-tabs">
|
<el-tabs v-model="activeTab" type="border-card" class="jdr-tabs">
|
||||||
<!-- Tab 1: JD-R 维度分析 -->
|
|
||||||
<el-tab-pane label="维度分析" name="dimensions">
|
<el-tab-pane label="维度分析" name="dimensions">
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :xs="24" :lg="12">
|
<el-col :xs="24" :lg="12">
|
||||||
<el-card class="panel-card" shadow="never">
|
<el-card class="panel-card" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h3 class="section-title">JD-R 维度雷达图</h3>
|
<h3 class="section-title">维度雷达图</h3>
|
||||||
<p class="section-caption">工作要求、工作资源与个人资源三维度均值对比</p>
|
<p class="section-caption">工作要求、工作资源与个人资源</p>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="dimensionData" ref="radarChartRef" style="height: 380px"></div>
|
<div v-if="dimensionData" ref="radarChartRef" style="height: 380px"></div>
|
||||||
<el-empty v-else description="加载中..." />
|
<el-empty v-else description="加载中..." />
|
||||||
@@ -26,7 +24,7 @@
|
|||||||
<el-card class="panel-card" shadow="never">
|
<el-card class="panel-card" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h3 class="section-title">维度分布统计</h3>
|
<h3 class="section-title">维度分布统计</h3>
|
||||||
<p class="section-caption">各维度的均值、标准差与平衡度</p>
|
<p class="section-caption">均值、标准差与平衡度</p>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="dimensionData" class="dim-stats">
|
<div v-if="dimensionData" class="dim-stats">
|
||||||
<div v-for="(item, key) in dimensionLabels" :key="key" class="dim-stat-row">
|
<div v-for="(item, key) in dimensionLabels" :key="key" class="dim-stat-row">
|
||||||
@@ -35,7 +33,7 @@
|
|||||||
<div class="dim-stat-sub">std: {{ dimensionData[key]?.std || '-' }}</div>
|
<div class="dim-stat-sub">std: {{ dimensionData[key]?.std || '-' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="dimensionData?.balance" class="dim-stat-row dim-stat-balance">
|
<div v-if="dimensionData?.balance" class="dim-stat-row dim-stat-balance">
|
||||||
<div class="dim-stat-label">JD-R 平衡度</div>
|
<div class="dim-stat-label">平衡度</div>
|
||||||
<div class="dim-stat-value">{{ dimensionData.balance.mean }}</div>
|
<div class="dim-stat-value">{{ dimensionData.balance.mean }}</div>
|
||||||
<div class="dim-stat-sub">正向比例: {{ dimensionData.balance.positive_ratio }}%</div>
|
<div class="dim-stat-sub">正向比例: {{ dimensionData.balance.positive_ratio }}%</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,14 +44,13 @@
|
|||||||
</el-row>
|
</el-row>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- Tab 2: 倦怠与投入 -->
|
|
||||||
<el-tab-pane label="倦怠与投入" name="burnout">
|
<el-tab-pane label="倦怠与投入" name="burnout">
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :xs="24" :lg="12">
|
<el-col :xs="24" :lg="12">
|
||||||
<el-card class="panel-card" shadow="never">
|
<el-card class="panel-card" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h3 class="section-title">倦怠与投入分布</h3>
|
<h3 class="section-title">倦怠与投入分布</h3>
|
||||||
<p class="section-caption">工作倦怠(1-7)和工作投入(1-7)的分布对比</p>
|
<p class="section-caption">工作倦怠与工作投入分布</p>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="burnoutData" ref="burnoutChartRef" style="height: 380px"></div>
|
<div v-if="burnoutData" ref="burnoutChartRef" style="height: 380px"></div>
|
||||||
<el-empty v-else description="加载中..." />
|
<el-empty v-else description="加载中..." />
|
||||||
@@ -63,7 +60,7 @@
|
|||||||
<el-card class="panel-card" shadow="never">
|
<el-card class="panel-card" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h3 class="section-title">关键相关性</h3>
|
<h3 class="section-title">关键相关性</h3>
|
||||||
<p class="section-caption">JD-R 维度与缺勤时长之间的关联强度</p>
|
<p class="section-caption">理论维度与缺勤时长</p>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="burnoutData" ref="corrChartRef" style="height: 380px"></div>
|
<div v-if="burnoutData" ref="corrChartRef" style="height: 380px"></div>
|
||||||
<el-empty v-else description="加载中..." />
|
<el-empty v-else description="加载中..." />
|
||||||
@@ -72,14 +69,13 @@
|
|||||||
</el-row>
|
</el-row>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- Tab 3: 双路径分析 -->
|
|
||||||
<el-tab-pane label="双路径分析" name="path">
|
<el-tab-pane label="双路径分析" name="path">
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :xs="24" :lg="14">
|
<el-col :xs="24" :lg="14">
|
||||||
<el-card class="panel-card" shadow="never">
|
<el-card class="panel-card" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h3 class="section-title">JD-R 双路径理论模型</h3>
|
<h3 class="section-title">双路径理论模型</h3>
|
||||||
<p class="section-caption">健康损伤路径(需求→倦怠→缺勤)与激励路径(资源→投入→低缺勤)</p>
|
<p class="section-caption">健康损伤路径与激励路径</p>
|
||||||
</template>
|
</template>
|
||||||
<div class="path-diagram">
|
<div class="path-diagram">
|
||||||
<div class="path-flow">
|
<div class="path-flow">
|
||||||
@@ -122,7 +118,7 @@
|
|||||||
<el-card class="panel-card" shadow="never">
|
<el-card class="panel-card" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h3 class="section-title">风险等级分布</h3>
|
<h3 class="section-title">风险等级分布</h3>
|
||||||
<p class="section-caption">全员缺勤风险等级统计</p>
|
<p class="section-caption">全员风险等级统计</p>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="riskData" ref="riskChartRef" style="height: 320px"></div>
|
<div v-if="riskData" ref="riskChartRef" style="height: 320px"></div>
|
||||||
<el-empty v-else description="加载中..." />
|
<el-empty v-else description="加载中..." />
|
||||||
@@ -131,22 +127,21 @@
|
|||||||
</el-row>
|
</el-row>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- Tab 4: SHAP 解释 -->
|
<el-tab-pane label="特征贡献" name="shap">
|
||||||
<el-tab-pane label="SHAP 解释" name="shap">
|
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :xs="24" :lg="14">
|
<el-col :xs="24" :lg="14">
|
||||||
<el-card class="panel-card" shadow="never">
|
<el-card class="panel-card" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="section-heading" style="margin-bottom:0">
|
<div class="section-heading" style="margin-bottom:0">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="section-title">全局特征重要性 (SHAP)</h3>
|
<h3 class="section-title">全局特征重要性</h3>
|
||||||
<p class="section-caption">按 SHAP 值排列的特征贡献度</p>
|
<p class="section-caption">特征贡献排序</p>
|
||||||
</div>
|
</div>
|
||||||
<el-select v-model="shapModel" size="small" style="width: 160px" @change="loadShapGlobal">
|
<el-select v-model="shapModel" size="small" style="width: 160px" @change="loadShapGlobal">
|
||||||
<el-option label="随机森林" value="random_forest" />
|
<el-option label="随机森林" value="random_forest" />
|
||||||
<el-option label="XGBoost" value="xgboost" />
|
<el-option label="增强树模型一" value="xgboost" />
|
||||||
<el-option label="LightGBM" value="lightgbm" />
|
<el-option label="增强树模型二" value="lightgbm" />
|
||||||
<el-option label="GBDT" value="gradient_boosting" />
|
<el-option label="梯度提升树" value="gradient_boosting" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -158,7 +153,7 @@
|
|||||||
<el-card class="panel-card" shadow="never">
|
<el-card class="panel-card" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h3 class="section-title">维度贡献占比</h3>
|
<h3 class="section-title">维度贡献占比</h3>
|
||||||
<p class="section-caption">按 JD-R 理论维度聚合的 SHAP 贡献</p>
|
<p class="section-caption">理论维度聚合贡献</p>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="shapGlobalData" ref="shapDimPieRef" style="height: 420px"></div>
|
<div v-if="shapGlobalData" ref="shapDimPieRef" style="height: 420px"></div>
|
||||||
<el-empty v-else description="加载中..." />
|
<el-empty v-else description="加载中..." />
|
||||||
@@ -170,7 +165,7 @@
|
|||||||
<el-card class="panel-card" shadow="never">
|
<el-card class="panel-card" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h3 class="section-title">特征依赖图</h3>
|
<h3 class="section-title">特征依赖图</h3>
|
||||||
<p class="section-caption">选择特征查看其取值与 SHAP 值的关系</p>
|
<p class="section-caption">特征取值与贡献关系</p>
|
||||||
</template>
|
</template>
|
||||||
<div style="margin-bottom: 12px">
|
<div style="margin-bottom: 12px">
|
||||||
<el-select v-model="dependenceFeature" size="small" style="width: 200px" @change="loadDependence">
|
<el-select v-model="dependenceFeature" size="small" style="width: 200px" @change="loadDependence">
|
||||||
@@ -185,7 +180,7 @@
|
|||||||
<el-card class="panel-card" shadow="never">
|
<el-card class="panel-card" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h3 class="section-title">特征交互强度</h3>
|
<h3 class="section-title">特征交互强度</h3>
|
||||||
<p class="section-caption">Top 特征对的交互效应</p>
|
<p class="section-caption">主要特征对交互效应</p>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="shapGlobalData" ref="shapInteractionRef" style="height: 320px"></div>
|
<div v-if="shapGlobalData" ref="shapInteractionRef" style="height: 320px"></div>
|
||||||
<el-empty v-else description="加载中..." />
|
<el-empty v-else description="加载中..." />
|
||||||
@@ -239,6 +234,18 @@ function getOrCreateChart(el) {
|
|||||||
return chart
|
return chart
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearShapViews() {
|
||||||
|
shapGlobalData.value = null
|
||||||
|
shapTopFeatures.value = []
|
||||||
|
const refs = [shapGlobalRef.value, shapDimPieRef.value, shapDependenceRef.value, shapInteractionRef.value]
|
||||||
|
refs.forEach(el => {
|
||||||
|
const chart = getOrCreateChart(el)
|
||||||
|
if (chart) {
|
||||||
|
chart.clear()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 延迟渲染:等待 Vue 将 v-if 的 DOM 插入到页面
|
// 延迟渲染:等待 Vue 将 v-if 的 DOM 插入到页面
|
||||||
function scheduleRender(fn) {
|
function scheduleRender(fn) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -265,7 +272,7 @@ function renderRadarChart() {
|
|||||||
{ name: '工作要求', max: 10 },
|
{ name: '工作要求', max: 10 },
|
||||||
{ name: '工作资源', max: 5 },
|
{ name: '工作资源', max: 5 },
|
||||||
{ name: '个人资源', max: 5 },
|
{ name: '个人资源', max: 5 },
|
||||||
{ name: 'JD-R平衡度', max: 5 },
|
{ name: '平衡度', max: 5 },
|
||||||
]
|
]
|
||||||
const values = [
|
const values = [
|
||||||
dims.demands?.mean || 0,
|
dims.demands?.mean || 0,
|
||||||
@@ -281,7 +288,7 @@ function renderRadarChart() {
|
|||||||
type: 'radar',
|
type: 'radar',
|
||||||
data: [{
|
data: [{
|
||||||
value: values,
|
value: values,
|
||||||
name: 'JD-R 维度均值',
|
name: '维度均值',
|
||||||
areaStyle: { color: 'rgba(15, 118, 110, 0.2)' },
|
areaStyle: { color: 'rgba(15, 118, 110, 0.2)' },
|
||||||
lineStyle: { color: '#0f766e', width: 2 },
|
lineStyle: { color: '#0f766e', width: 2 },
|
||||||
itemStyle: { color: '#0f766e' },
|
itemStyle: { color: '#0f766e' },
|
||||||
@@ -386,12 +393,16 @@ function renderRiskChart() {
|
|||||||
// ── Tab 4: SHAP ──
|
// ── Tab 4: SHAP ──
|
||||||
async function loadShapGlobal() {
|
async function loadShapGlobal() {
|
||||||
if (activeTab.value !== 'shap') return
|
if (activeTab.value !== 'shap') return
|
||||||
|
clearShapViews()
|
||||||
try {
|
try {
|
||||||
const data = await getGlobalImportance(shapModel.value)
|
const data = await getGlobalImportance(shapModel.value)
|
||||||
if (data.error) { ElMessage.error(data.error); return }
|
if (data.error) {
|
||||||
|
ElMessage.error(data.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
shapGlobalData.value = data
|
shapGlobalData.value = data
|
||||||
shapTopFeatures.value = data.top_features || []
|
shapTopFeatures.value = data.top_features || []
|
||||||
if (shapTopFeatures.value.length && !dependenceFeature.value) {
|
if (shapTopFeatures.value.length) {
|
||||||
dependenceFeature.value = shapTopFeatures.value[0].name
|
dependenceFeature.value = shapTopFeatures.value[0].name
|
||||||
}
|
}
|
||||||
scheduleRender(() => {
|
scheduleRender(() => {
|
||||||
@@ -401,7 +412,7 @@ async function loadShapGlobal() {
|
|||||||
loadInteractions()
|
loadInteractions()
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('加载 SHAP 数据失败')
|
ElMessage.error('加载特征贡献数据失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,7 +433,7 @@ function renderShapGlobalChart() {
|
|||||||
chart.setOption({
|
chart.setOption({
|
||||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
grid: { left: 140, right: 30, top: 10, bottom: 30 },
|
grid: { left: 140, right: 30, top: 10, bottom: 30 },
|
||||||
xAxis: { type: 'value', name: 'Mean |SHAP|' },
|
xAxis: { type: 'value', name: '平均贡献值' },
|
||||||
yAxis: { type: 'category', data: features.map(f => f.name_cn) },
|
yAxis: { type: 'category', data: features.map(f => f.name_cn) },
|
||||||
series: [{
|
series: [{
|
||||||
type: 'bar', data: features.map(f => ({
|
type: 'bar', data: features.map(f => ({
|
||||||
@@ -465,17 +476,22 @@ async function loadDependence() {
|
|||||||
if (!dependenceFeature.value) return
|
if (!dependenceFeature.value) return
|
||||||
try {
|
try {
|
||||||
const data = await getDependence(dependenceFeature.value, shapModel.value)
|
const data = await getDependence(dependenceFeature.value, shapModel.value)
|
||||||
if (data.error) return
|
if (data.error) {
|
||||||
|
const chart = getOrCreateChart(shapDependenceRef.value)
|
||||||
|
if (chart) chart.clear()
|
||||||
|
ElMessage.error(data.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const chart = getOrCreateChart(shapDependenceRef.value)
|
const chart = getOrCreateChart(shapDependenceRef.value)
|
||||||
if (!chart) return
|
if (!chart) return
|
||||||
|
|
||||||
const points = data.values.map((v, i) => [v, data.shap_values[i]])
|
const points = data.values.map((v, i) => [v, data.shap_values[i]])
|
||||||
chart.setOption({
|
chart.setOption({
|
||||||
tooltip: { trigger: 'item', formatter: (p) => `值: ${p.data[0].toFixed(2)}<br/>SHAP: ${p.data[1].toFixed(4)}` },
|
tooltip: { trigger: 'item', formatter: (p) => `值: ${p.data[0].toFixed(2)}<br/>贡献值: ${p.data[1].toFixed(4)}` },
|
||||||
grid: { left: 60, right: 20, top: 20, bottom: 40 },
|
grid: { left: 60, right: 20, top: 20, bottom: 40 },
|
||||||
xAxis: { type: 'value', name: data.feature_cn },
|
xAxis: { type: 'value', name: data.feature_cn },
|
||||||
yAxis: { type: 'value', name: 'SHAP value' },
|
yAxis: { type: 'value', name: '贡献值' },
|
||||||
series: [{
|
series: [{
|
||||||
type: 'scatter', data: points, symbolSize: 5,
|
type: 'scatter', data: points, symbolSize: 5,
|
||||||
itemStyle: { color: '#0f766e', opacity: 0.6 },
|
itemStyle: { color: '#0f766e', opacity: 0.6 },
|
||||||
@@ -488,7 +504,12 @@ async function loadInteractions() {
|
|||||||
if (activeTab.value !== 'shap') return
|
if (activeTab.value !== 'shap') return
|
||||||
try {
|
try {
|
||||||
const data = await getInteractions(shapModel.value, 10)
|
const data = await getInteractions(shapModel.value, 10)
|
||||||
if (data.error || !data.top_interactions) return
|
if (data.error || !data.top_interactions) {
|
||||||
|
const chart = getOrCreateChart(shapInteractionRef.value)
|
||||||
|
if (chart) chart.clear()
|
||||||
|
if (data.error) ElMessage.error(data.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const chart = getOrCreateChart(shapInteractionRef.value)
|
const chart = getOrCreateChart(shapInteractionRef.value)
|
||||||
if (!chart) return
|
if (!chart) return
|
||||||
@@ -498,7 +519,7 @@ async function loadInteractions() {
|
|||||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
grid: { left: 160, right: 20, top: 10, bottom: 30 },
|
grid: { left: 160, right: 20, top: 10, bottom: 30 },
|
||||||
xAxis: { type: 'value', name: '交互强度' },
|
xAxis: { type: 'value', name: '交互强度' },
|
||||||
yAxis: { type: 'category', data: interactions.map(i => `${i.feature_1_cn} x ${i.feature_2_cn}`) },
|
yAxis: { type: 'category', data: interactions.map(i => `${i.feature_1_cn}与${i.feature_2_cn}`) },
|
||||||
series: [{
|
series: [{
|
||||||
type: 'bar', data: interactions.map(i => i.strength),
|
type: 'bar', data: interactions.map(i => i.strength),
|
||||||
itemStyle: { color: '#f59e0b' },
|
itemStyle: { color: '#f59e0b' },
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-shell prediction">
|
<div class="page-shell prediction">
|
||||||
<section class="page-hero prediction-hero">
|
<section class="page-hero prediction-hero">
|
||||||
<div class="page-eyebrow">Prediction</div>
|
|
||||||
<h1 class="page-title">核心因子驱动的缺勤预测</h1>
|
<h1 class="page-title">核心因子驱动的缺勤预测</h1>
|
||||||
<p class="page-description">
|
<p class="page-description">
|
||||||
仅保留对结果最关键的输入项,让演示流程更聚焦,也让答辩老师更容易理解模型的业务逻辑。
|
基于缺勤事件、工作压力、家庭负担与岗位背景,评估缺勤时长和风险等级。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -15,12 +14,12 @@
|
|||||||
<div class="section-heading" style="margin-bottom: 0">
|
<div class="section-heading" style="margin-bottom: 0">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="section-title">中国企业缺勤风险输入</h3>
|
<h3 class="section-title">中国企业缺勤风险输入</h3>
|
||||||
<p class="section-caption">使用卡片分区组织核心因子,演示时更清晰。</p>
|
<p class="section-caption">核心字段分区录入</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button size="small" @click="resetForm">重置</el-button>
|
<el-button size="small" @click="resetForm">重置</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-tip">
|
<div class="form-tip">
|
||||||
系统会自动补齐企业背景、健康生活与组织属性等次级信息,页面仅保留对预测结果影响最大的核心字段。
|
后端将结合默认企业背景、健康生活与组织属性完成特征补齐。
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
@@ -28,9 +27,8 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="section-title">缺勤事件核心信息</h3>
|
<h3 class="section-title">缺勤事件核心信息</h3>
|
||||||
<p class="section-caption">决定本次缺勤时长的直接事件属性。</p>
|
<p class="section-caption">请假类型、时间与证明信息</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="soft-tag">Event</span>
|
|
||||||
</div>
|
</div>
|
||||||
<el-form :model="form" label-width="118px" size="small">
|
<el-form :model="form" label-width="118px" size="small">
|
||||||
<el-row :gutter="18">
|
<el-row :gutter="18">
|
||||||
@@ -86,9 +84,8 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="section-title">工作压力与排班</h3>
|
<h3 class="section-title">工作压力与排班</h3>
|
||||||
<p class="section-caption">体现通勤、加班和排班对缺勤的影响。</p>
|
<p class="section-caption">加班、通勤、班次与健康状态</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="soft-tag">Workload</span>
|
|
||||||
</div>
|
</div>
|
||||||
<el-form :model="form" label-width="118px" size="small">
|
<el-form :model="form" label-width="118px" size="small">
|
||||||
<el-row :gutter="18">
|
<el-row :gutter="18">
|
||||||
@@ -133,9 +130,8 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="section-title">家庭与补充因素</h3>
|
<h3 class="section-title">家庭与补充因素</h3>
|
||||||
<p class="section-caption">作为结果修正项,为预测增加业务语境。</p>
|
<p class="section-caption">家庭负担、行业与婚姻状态</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="soft-tag">Context</span>
|
|
||||||
</div>
|
</div>
|
||||||
<el-form :model="form" label-width="118px" size="small">
|
<el-form :model="form" label-width="118px" size="small">
|
||||||
<el-row :gutter="18">
|
<el-row :gutter="18">
|
||||||
@@ -166,9 +162,8 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="section-title">预测设置</h3>
|
<h3 class="section-title">预测设置</h3>
|
||||||
<p class="section-caption">支持自动选择最优模型或查看模型对比。</p>
|
<p class="section-caption">模型选择与对比</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="soft-tag">Action</span>
|
|
||||||
</div>
|
</div>
|
||||||
<el-form :model="form" label-width="118px" size="small">
|
<el-form :model="form" label-width="118px" size="small">
|
||||||
<el-row :gutter="18">
|
<el-row :gutter="18">
|
||||||
@@ -203,10 +198,9 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="section-heading" style="margin-bottom: 0">
|
<div class="section-heading" style="margin-bottom: 0">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="section-title">预测结果与风险说明</h3>
|
<h3 class="section-title">预测结果与风险等级</h3>
|
||||||
<p class="section-caption">在同一张卡片内查看预测值、模型信息和风险区间说明。</p>
|
<p class="section-caption">缺勤时长、风险等级与模型信息</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="soft-tag">Result</span>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="merged-result-grid">
|
<div class="merged-result-grid">
|
||||||
@@ -264,19 +258,90 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="result?.jdr_snapshot" class="insight-stack">
|
||||||
|
<section class="insight-block">
|
||||||
|
<div class="insight-heading">
|
||||||
|
<div>
|
||||||
|
<h3 class="section-title">JD-R 快照</h3>
|
||||||
|
<p class="section-caption">工作要求、资源与平衡度</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="snapshot-grid">
|
||||||
|
<div v-for="item in jdrSnapshotCards" :key="item.key" class="snapshot-card">
|
||||||
|
<div class="snapshot-label">{{ item.label }}</div>
|
||||||
|
<div class="snapshot-score">{{ item.score }}</div>
|
||||||
|
<el-tag :type="item.tone" size="small">{{ item.status }}</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="mechanismSummary" class="insight-block">
|
||||||
|
<div class="insight-heading">
|
||||||
|
<div>
|
||||||
|
<h3 class="section-title">风险机制</h3>
|
||||||
|
<p class="section-caption">当前样本的主要风险路径</p>
|
||||||
|
</div>
|
||||||
|
<el-tag v-if="mechanismSummary.pathway_label" :type="mechanismSummary.pathway_tone || 'info'" effect="light">
|
||||||
|
{{ mechanismSummary.pathway_label }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="summary-text">
|
||||||
|
<p>{{ mechanismSummary.conclusion }}</p>
|
||||||
|
<p>{{ mechanismSummary.mechanism }}</p>
|
||||||
|
<p>{{ mechanismSummary.pathway_detail }}</p>
|
||||||
|
<p v-if="mechanismSummary.buffer_text">{{ mechanismSummary.buffer_text }}</p>
|
||||||
|
<p v-if="mechanismSummary.scenario_hint">{{ mechanismSummary.scenario_hint }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="mechanismSummary.top_drivers?.length" class="driver-group">
|
||||||
|
<div class="driver-label">主要推高因素</div>
|
||||||
|
<div class="driver-chip-row">
|
||||||
|
<span v-for="item in mechanismSummary.top_drivers" :key="`up-${item.name}`" class="driver-chip driver-chip-up">
|
||||||
|
{{ item.name_cn }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="mechanismSummary.protective_factors?.length" class="driver-group">
|
||||||
|
<div class="driver-label">缓冲因素</div>
|
||||||
|
<div class="driver-chip-row">
|
||||||
|
<span v-for="item in mechanismSummary.protective_factors" :key="`down-${item.name}`" class="driver-chip driver-chip-down">
|
||||||
|
{{ item.name_cn }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="interventionGroups.length" class="insight-block">
|
||||||
|
<div class="insight-heading">
|
||||||
|
<div>
|
||||||
|
<h3 class="section-title">干预建议</h3>
|
||||||
|
<p class="section-caption">管理关注方向</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="suggestion-grid">
|
||||||
|
<div v-for="group in interventionGroups" :key="group.category" class="suggestion-card">
|
||||||
|
<div class="suggestion-title">{{ group.category }}</div>
|
||||||
|
<div class="suggestion-list">
|
||||||
|
<p v-for="item in group.items" :key="item">{{ item }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card v-if="compareResults.length > 0" class="panel-card compare-card" shadow="never">
|
<el-card v-if="shouldShowCompareCard" v-loading="compareLoading" class="panel-card compare-card" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="section-heading" style="margin-bottom: 0">
|
<div class="section-heading" style="margin-bottom: 0">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="section-title">模型对比结果</h3>
|
<h3 class="section-title">模型对比结果</h3>
|
||||||
<p class="section-caption">选择最适合展示的候选模型。</p>
|
<p class="section-caption">不同模型预测结果对比</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="soft-tag">Compare</span>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-table :data="compareResults" size="small" :row-class-name="getRowClass">
|
<el-table v-if="compareResults.length > 0" :data="compareResults" size="small" :row-class-name="getRowClass">
|
||||||
<el-table-column prop="model_name_cn" label="模型" width="100" />
|
<el-table-column prop="model_name_cn" label="模型" width="100" />
|
||||||
<el-table-column prop="predicted_hours" label="预测时长" width="90">
|
<el-table-column prop="predicted_hours" label="预测时长" width="90">
|
||||||
<template #default="{ row }">{{ row.predicted_hours }}h</template>
|
<template #default="{ row }">{{ row.predicted_hours }}h</template>
|
||||||
@@ -295,16 +360,16 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
<el-empty v-else description="暂无模型对比结果" />
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card v-if="shapLocalData" class="panel-card shap-card" shadow="never">
|
<el-card v-if="shapLocalData" class="panel-card shap-card" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="section-heading" style="margin-bottom: 0">
|
<div class="section-heading" style="margin-bottom: 0">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="section-title">SHAP 预测解释</h3>
|
<h3 class="section-title">特征贡献</h3>
|
||||||
<p class="section-caption">每个特征对本次预测的贡献度(红色推高/蓝色拉低)</p>
|
<p class="section-caption">特征贡献方向与贡献强度</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="soft-tag">Explain</span>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="shap-dimension-badges">
|
<div class="shap-dimension-badges">
|
||||||
@@ -322,11 +387,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import request from '@/api/request'
|
import request from '@/api/request'
|
||||||
import { getLocalExplanation } from '@/api/shap'
|
|
||||||
|
|
||||||
const industries = ['制造业', '互联网', '零售连锁', '物流运输', '金融服务', '医药健康', '建筑工程']
|
const industries = ['制造业', '互联网', '零售连锁', '物流运输', '金融服务', '医药健康', '建筑工程']
|
||||||
const shiftTypes = ['标准白班', '两班倒', '三班倒', '弹性班']
|
const shiftTypes = ['标准白班', '两班倒', '三班倒', '弹性班']
|
||||||
@@ -377,6 +441,23 @@ const riskTagType = computed(() => {
|
|||||||
return getRiskType(result.value.risk_level)
|
return getRiskType(result.value.risk_level)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const jdrSnapshotCards = computed(() => {
|
||||||
|
const snapshot = result.value?.jdr_snapshot
|
||||||
|
if (!snapshot) return []
|
||||||
|
return [
|
||||||
|
snapshot.job_demands,
|
||||||
|
snapshot.job_resources,
|
||||||
|
snapshot.personal_resources,
|
||||||
|
snapshot.balance,
|
||||||
|
snapshot.burnout_risk,
|
||||||
|
snapshot.engagement,
|
||||||
|
].filter(Boolean)
|
||||||
|
})
|
||||||
|
|
||||||
|
const mechanismSummary = computed(() => result.value?.mechanism_summary || null)
|
||||||
|
const interventionGroups = computed(() => result.value?.intervention_suggestions || [])
|
||||||
|
const shouldShowCompareCard = computed(() => showCompare.value || compareLoading.value || compareResults.value.length > 0)
|
||||||
|
|
||||||
function getRiskType(level) {
|
function getRiskType(level) {
|
||||||
return level === 'low' ? 'success' : level === 'medium' ? 'warning' : 'danger'
|
return level === 'low' ? 'success' : level === 'medium' ? 'warning' : 'danger'
|
||||||
}
|
}
|
||||||
@@ -408,9 +489,13 @@ async function handlePredict() {
|
|||||||
const params = { ...form.value }
|
const params = { ...form.value }
|
||||||
if (selectedModel.value) params.model_type = selectedModel.value
|
if (selectedModel.value) params.model_type = selectedModel.value
|
||||||
result.value = await request.post('/predict/single', params)
|
result.value = await request.post('/predict/single', params)
|
||||||
|
shapLocalData.value = result.value?.shap_local || null
|
||||||
|
if (shapLocalData.value?.features?.length) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
nextTick().then(renderShapForce)
|
||||||
|
})
|
||||||
|
}
|
||||||
if (showCompare.value) await handleCompare()
|
if (showCompare.value) await handleCompare()
|
||||||
// 加载 SHAP 局部解释
|
|
||||||
loadShapLocal(params)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error(`预测失败: ${e.message}`)
|
ElMessage.error(`预测失败: ${e.message}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -418,19 +503,6 @@ async function handlePredict() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadShapLocal(params) {
|
|
||||||
try {
|
|
||||||
const modelType = params.model_type || ''
|
|
||||||
const data = await getLocalExplanation({ ...params, model_type: modelType })
|
|
||||||
if (data && !data.error) {
|
|
||||||
shapLocalData.value = data
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
nextTick().then(renderShapForce)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderShapForce() {
|
function renderShapForce() {
|
||||||
const el = shapForceRef.value
|
const el = shapForceRef.value
|
||||||
if (!el || !shapLocalData.value?.features) { console.warn('shapForce: DOM or data missing'); return }
|
if (!el || !shapLocalData.value?.features) { console.warn('shapForce: DOM or data missing'); return }
|
||||||
@@ -443,7 +515,7 @@ function renderShapForce() {
|
|||||||
chart.setOption({
|
chart.setOption({
|
||||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
grid: { left: 120, right: 30, top: 10, bottom: 30 },
|
grid: { left: 120, right: 30, top: 10, bottom: 30 },
|
||||||
xAxis: { type: 'value', name: 'SHAP值' },
|
xAxis: { type: 'value', name: '贡献值' },
|
||||||
yAxis: { type: 'category', data: sorted.map(f => f.name_cn) },
|
yAxis: { type: 'category', data: sorted.map(f => f.name_cn) },
|
||||||
series: [{
|
series: [{
|
||||||
type: 'bar', data: sorted.map(f => ({
|
type: 'bar', data: sorted.map(f => ({
|
||||||
@@ -457,6 +529,7 @@ function renderShapForce() {
|
|||||||
async function handleCompare() {
|
async function handleCompare() {
|
||||||
compareLoading.value = true
|
compareLoading.value = true
|
||||||
try {
|
try {
|
||||||
|
showCompare.value = true
|
||||||
const res = await request.post('/predict/compare', form.value)
|
const res = await request.post('/predict/compare', form.value)
|
||||||
compareResults.value = res.results || []
|
compareResults.value = res.results || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -469,6 +542,16 @@ async function handleCompare() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadModels()
|
loadModels()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(showCompare, (visible) => {
|
||||||
|
if (!visible) {
|
||||||
|
compareResults.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!compareLoading.value && compareResults.value.length === 0) {
|
||||||
|
handleCompare()
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -643,10 +726,133 @@ onMounted(() => {
|
|||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.insight-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-block {
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--line-soft);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-card {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, rgba(58, 122, 254, 0.06), rgba(15, 118, 110, 0.08));
|
||||||
|
border: 1px solid rgba(58, 122, 254, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-score {
|
||||||
|
margin: 8px 0 12px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-text {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-text p,
|
||||||
|
.suggestion-list p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-group + .driver-group {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-group {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-label,
|
||||||
|
.suggestion-title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-chip-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-chip {
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-chip-up {
|
||||||
|
color: #ef4444;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-chip-down {
|
||||||
|
color: #2563eb;
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-card {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--line-soft);
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(244, 248, 255, 0.9));
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.prediction-input-grid {
|
.prediction-input-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.snapshot-grid,
|
||||||
|
.suggestion-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -654,6 +860,15 @@ onMounted(() => {
|
|||||||
.result-stack {
|
.result-stack {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.snapshot-grid,
|
||||||
|
.suggestion-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-heading {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.shap-card {
|
.shap-card {
|
||||||
|
|||||||
Reference in New Issue
Block a user