-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathanalysis_script.py
More file actions
831 lines (687 loc) · 34.5 KB
/
Copy pathanalysis_script.py
File metadata and controls
831 lines (687 loc) · 34.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
# -*- coding: utf-8 -*-
"""
电商用户行为数据分析脚本
数据集:电子产品销售分析.csv
"""
import os
import re
import shutil
from datetime import datetime
import numpy as np
import pandas as pd
import matplotlib
import matplotlib.pyplot as plt
import seaborn as sns
# ============================================================
# 配置 matplotlib 中文显示与样式
# ============================================================
plt.rcParams['font.sans-serif'] = ['SimHei'] # 中文字体
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题
plt.rcParams['figure.dpi'] = 300
sns.set_palette("husl")
# ============================================================
# 全局路径配置
# ============================================================
CSV_FILE = 'data/电子产品销售分析.csv'
OUTPUT_DIR = 'output'
CHARTS_DIR = os.path.join(OUTPUT_DIR, 'charts')
CLEANED_CSV = os.path.join(OUTPUT_DIR, 'cleaned_data.csv')
REPORT_MD = os.path.join(OUTPUT_DIR, '数据分析报告.md')
# 存储分析结果,用于生成报告
RESULTS = {}
def ensure_dirs():
"""创建输出目录"""
os.makedirs(OUTPUT_DIR, exist_ok=True)
os.makedirs(CHARTS_DIR, exist_ok=True)
print(f"输出目录已创建/确认:{OUTPUT_DIR}")
def log_step(step_title):
"""打印步骤分隔线"""
print("\n" + "=" * 70)
print(step_title)
print("=" * 70)
# ============================================================
# 步骤 1:数据加载与初步查看
# ============================================================
def step1_load_data():
log_step("步骤 1:数据加载与初步查看")
# 使用 utf-8 编码读取 CSV
df = pd.read_csv(CSV_FILE)
RESULTS['raw_shape'] = df.shape
print(f"数据形状:{df.shape[0]} 行 × {df.shape[1]} 列")
print("\n列名:")
print(df.columns.tolist())
print("\n前 5 行数据:")
print(df.head().to_string())
print("\n数据类型:")
print(df.dtypes.to_string())
print("\n基本统计信息(数值型):")
print(df.describe().to_string())
# 缺失值统计
missing = df.isnull().sum()
missing_pct = (missing / len(df) * 100).round(2)
missing_df = pd.DataFrame({
'缺失数量': missing,
'缺失比例(%)': missing_pct
})
RESULTS['missing_raw'] = missing_df
print("\n缺失值情况:")
print(missing_df.to_string())
# 重复行统计
dup_count = df.duplicated().sum()
RESULTS['duplicate_count'] = dup_count
print(f"\n完全重复行数:{dup_count}")
return df
# ============================================================
# 步骤 2:数据清洗
# ============================================================
def step2_clean_data(df):
log_step("步骤 2:数据清洗")
raw_rows = len(df)
# 1. 去重:删除完全重复的行
df = df.drop_duplicates()
after_dup = len(df)
dup_removed = raw_rows - after_dup
print(f"1. 去重完成:删除 {dup_removed} 行完全重复记录,剩余 {after_dup} 行")
# 2. 缺失值处理
# brand 缺失填充为 "未知品牌"
brand_missing_before = df['brand'].isnull().sum()
df['brand'] = df['brand'].fillna('未知品牌')
print(f"2. brand 缺失填充完成:{brand_missing_before} 条填充为 '未知品牌'")
# 其他列缺失值酌情处理
# age 缺失:用中位数填充(年龄没有明显偏态时中位数更稳健)
age_missing = df['age'].isnull().sum()
if age_missing > 0:
median_age = df['age'].median()
df['age'] = df['age'].fillna(median_age)
print(f" age 缺失 {age_missing} 条,已用年龄中位数 {median_age} 填充")
# sex 缺失:填充为 "未知"
sex_missing = df['sex'].isnull().sum()
if sex_missing > 0:
df['sex'] = df['sex'].fillna('未知')
print(f" sex 缺失 {sex_missing} 条,已填充为 '未知'")
# local 缺失:填充为 "未知地区"
local_missing = df['local'].isnull().sum()
if local_missing > 0:
df['local'] = df['local'].fillna('未知地区')
print(f" local 缺失 {local_missing} 条,已填充为 '未知地区'")
# 3. 价格异常值处理:price 为 0 或负数则删除
price_invalid = df[df['price'] <= 0].shape[0]
df = df[df['price'] > 0]
print(f"3. price 异常值处理完成:删除 {price_invalid} 条 price ≤ 0 的记录,剩余 {len(df)} 行")
# 4. 日期解析:event_time 转换为 datetime,并提取时间维度
# 原始格式示例:2020-04-24 11:50:39 UTC
df['event_time'] = pd.to_datetime(df['event_time'], utc=True)
# 转为中国时区(UTC+8),便于业务理解
df['event_time'] = df['event_time'].dt.tz_convert('Asia/Shanghai')
df['year'] = df['event_time'].dt.year.astype('category')
df['month'] = df['event_time'].dt.month.astype('category')
df['day'] = df['event_time'].dt.day.astype('category')
df['hour'] = df['event_time'].dt.hour.astype('category')
df['date'] = df['event_time'].dt.date
print("4. 日期解析完成:已提取 year/month/day/hour/date 字段")
# 4.1 时间异常值处理:删除年份不在合理范围(2000-2030)的记录
current_year = pd.Timestamp.now().year
time_invalid = df[(df['event_time'].dt.year < 2000) | (df['event_time'].dt.year > current_year)].shape[0]
df = df[(df['event_time'].dt.year >= 2000) & (df['event_time'].dt.year <= current_year)]
print(f"4.1 时间异常值处理完成:删除 {time_invalid} 条 event_time 年份异常记录,剩余 {len(df)} 行")
# 5. 品类拆分:category_code 按 "." 拆分为大类、中类、小类
def split_category(x):
if pd.isnull(x):
return pd.Series(['未知', '未知', '未知'])
parts = str(x).split('.')
# 保证返回 3 个元素
parts = parts + ['未知'] * (3 - len(parts))
return pd.Series(parts[:3])
cat_split = df['category_code'].apply(split_category)
cat_split.columns = ['category_l1', 'category_l2', 'category_l3']
df = pd.concat([df, cat_split], axis=1)
# 缺失标记
df['category_code_missing'] = df['category_code'].isnull()
print("5. 品类拆分完成:已拆分为 category_l1 / category_l2 / category_l3,缺失标记为 '未知'")
# 6. 年龄异常值处理:age 超过 100 或小于 0 则删除
age_invalid = df[(df['age'] < 0) | (df['age'] > 100)].shape[0]
df = df[(df['age'] >= 0) & (df['age'] <= 100)]
print(f"6. age 异常值处理完成:删除 {age_invalid} 条 age < 0 或 age > 100 的记录,剩余 {len(df)} 行")
# 7. 类型优化:category 相关列转为 category 类型
cat_cols = ['category_l1', 'category_l2', 'category_l3', 'brand', 'sex', 'local']
for col in cat_cols:
if col in df.columns:
df[col] = df[col].astype('category')
print("7. 类型优化完成:category / brand / sex / local 已转为 category 类型")
# 清洗前后行数对比
RESULTS['cleaned_rows'] = len(df)
RESULTS['rows_removed'] = raw_rows - len(df)
RESULTS['clean_shape'] = df.shape
print(f"\n清洗完成:原始 {raw_rows} 行 -> 清洗后 {len(df)} 行,共移除 {raw_rows - len(df)} 行")
return df
# ============================================================
# 步骤 3:探索性数据分析(EDA)
# ============================================================
def step3_eda(df):
log_step("步骤 3:探索性数据分析(EDA)")
# 1. 总体指标
order_lines = len(df) # 订单行数(原始记录数)
total_orders = df['order_id'].nunique() # 独立订单数(去重后的订单数)
total_sales = df['price'].sum()
unique_users = df['user_id'].nunique()
atv = total_sales / total_orders # 客单价 = 总销售额 / 独立订单数
unit_price = total_sales / order_lines # 件单价 = 总销售额 / 订单行数
RESULTS['order_lines'] = order_lines
RESULTS['total_orders'] = total_orders
RESULTS['total_sales'] = total_sales
RESULTS['unique_users'] = unique_users
RESULTS['atv'] = atv
RESULTS['unit_price'] = unit_price
print(f"1. 总体指标:")
print(f" 订单行数:{order_lines:,}")
print(f" 独立订单数:{total_orders:,}")
print(f" 总销售额:{total_sales:,.2f}")
print(f" 客单价:{atv:,.2f}")
print(f" 件单价:{unit_price:,.2f}")
print(f" 独立用户数:{unique_users:,}")
# 2. 时间维度:按月的销售额
monthly_sales = df.groupby(df['event_time'].dt.to_period('M'))['price'].sum().reset_index()
monthly_sales['event_time'] = monthly_sales['event_time'].astype(str)
RESULTS['monthly_sales'] = monthly_sales
print(f"\n2. 按月销售额趋势(前 10 条):")
print(monthly_sales.head(10).to_string(index=False))
# 按日的销售额
daily_sales = df.groupby(df['event_time'].dt.date)['price'].sum().reset_index()
daily_sales.columns = ['date', 'sales']
RESULTS['daily_sales'] = daily_sales
print(f"\n3. 按日销售额趋势(前 10 条):")
print(daily_sales.head(10).to_string(index=False))
# 3. 品类维度:各级品类销售额
l1_sales = df.groupby('category_l1', observed=False)['price'].sum().sort_values(ascending=False).reset_index()
l2_sales = df.groupby('category_l2', observed=False)['price'].sum().sort_values(ascending=False).reset_index()
l3_sales = df.groupby('category_l3', observed=False)['price'].sum().sort_values(ascending=False).reset_index()
RESULTS['l1_sales'] = l1_sales
RESULTS['l2_sales'] = l2_sales
RESULTS['l3_sales'] = l3_sales
print(f"\n4. 一级品类销售额 Top 10:")
print(l1_sales.head(10).to_string(index=False))
# 4. 用户维度
# 性别分布及消费金额
gender_dist = df['sex'].value_counts().reset_index()
gender_dist.columns = ['sex', 'count']
gender_sales = df.groupby('sex', observed=False)['price'].sum().reset_index()
gender_sales.columns = ['sex', 'sales']
gender_analysis = gender_dist.merge(gender_sales, on='sex')
gender_analysis['人均消费'] = gender_analysis['sales'] / gender_analysis['count']
RESULTS['gender_analysis'] = gender_analysis
print(f"\n5. 性别分布及消费金额:")
print(gender_analysis.to_string(index=False))
# 年龄分布(分箱)
bins = [0, 17, 25, 35, 45, 55, 100]
labels = ['<18', '18-25', '26-35', '36-45', '46-55', '55+']
df['age_group'] = pd.cut(df['age'], bins=bins, labels=labels, right=True)
age_dist = df['age_group'].value_counts().sort_index().reset_index()
age_dist.columns = ['age_group', 'count']
age_sales = df.groupby('age_group', observed=False)['price'].sum().reset_index()
age_sales.columns = ['age_group', 'sales']
age_analysis = age_dist.merge(age_sales, on='age_group')
RESULTS['age_analysis'] = age_analysis
print(f"\n6. 年龄分箱分布:")
print(age_analysis.to_string(index=False))
# 地区 Top10 消费排行
local_top10 = df.groupby('local', observed=False)['price'].sum().sort_values(ascending=False).head(10).reset_index()
local_top10.columns = ['local', 'sales']
RESULTS['local_top10'] = local_top10
print(f"\n7. 地区消费 Top 10:")
print(local_top10.to_string(index=False))
# 5. 价格维度
price_desc = df['price'].describe()
RESULTS['price_desc'] = price_desc
print(f"\n8. 价格统计描述:")
print(price_desc.to_string())
return df
# ============================================================
# 步骤 4:数据可视化
# ============================================================
def step4_visualization(df):
log_step("步骤 4:数据可视化(优化可读性版)")
# 通用字体大小
title_size = 15
label_size = 12
tick_size = 10
annot_size = 9
# 1. 销售额趋势(按月)——标注每月销售额(单位:万元)
plt.figure(figsize=(14, 6))
monthly = RESULTS['monthly_sales'].copy()
monthly['sales_wan'] = monthly['price'] / 10000.0
x_pos = np.arange(len(monthly))
plt.plot(x_pos, monthly['sales_wan'], marker='o', linewidth=2.5, markersize=8, color='#e74c3c')
# 在数据点上方标注数值
for i, v in enumerate(monthly['sales_wan']):
plt.annotate(f'{v:.1f}',
xy=(i, v),
xytext=(0, 10),
textcoords='offset points',
ha='center',
fontsize=annot_size,
color='#2c3e50')
plt.title('销售额趋势(按月)', fontsize=title_size, fontweight='bold')
plt.xlabel('月份', fontsize=label_size)
plt.ylabel('销售额(万元)', fontsize=label_size)
plt.xticks(x_pos, monthly['event_time'], rotation=45, ha='right', fontsize=tick_size)
plt.yticks(fontsize=tick_size)
plt.grid(True, alpha=0.3, linestyle='--')
plt.tight_layout()
save_path = os.path.join(CHARTS_DIR, '01_销售额趋势.png')
plt.savefig(save_path, dpi=300, bbox_inches='tight')
plt.close()
print(f"已保存:{save_path}")
# 2. 品类占比——改为水平条形图,避免饼图标签重叠,并标注销售额与占比
plt.figure(figsize=(11, 7))
l1 = RESULTS['l1_sales'].head(10).copy()
total = l1['price'].sum()
l1['pct'] = l1['price'] / total * 100
l1 = l1.sort_values('price', ascending=True)
colors = sns.color_palette("Spectral", len(l1))
bars = plt.barh(l1['category_l1'], l1['price'] / 10000.0, color=colors)
# 在条形右侧标注:销售额(万元)+ 占比
for bar, sales, pct in zip(bars, l1['price'], l1['pct']):
width = bar.get_width()
plt.text(width + 5, bar.get_y() + bar.get_height()/2,
f'{sales/10000:.1f}万 ({pct:.1f}%)',
va='center', ha='left', fontsize=annot_size, color='black')
plt.title('一级品类销售额占比(Top 10)', fontsize=title_size, fontweight='bold')
plt.xlabel('销售额(万元)', fontsize=label_size)
plt.ylabel('品类', fontsize=label_size)
plt.xlim(0, l1['price'].max() / 10000.0 * 1.35) # 留出标注空间
plt.tight_layout()
save_path = os.path.join(CHARTS_DIR, '02_品类占比.png')
plt.savefig(save_path, dpi=300, bbox_inches='tight')
plt.close()
print(f"已保存:{save_path}")
# 3. 性别消费对比——使用两个子图分别展示,避免双Y轴标签混淆
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
gender = RESULTS['gender_analysis'].copy()
colors_gender = ['#3498db', '#e91e63']
# 子图1:订单数
ax = axes[0]
bars = ax.bar(gender['sex'], gender['count'], color=colors_gender, edgecolor='white')
for bar in bars:
height = bar.get_height()
ax.text(bar.get_x() + bar.get_width()/2, height + height*0.01,
f'{int(height):,}',
ha='center', va='bottom', fontsize=annot_size)
ax.set_title('性别订单数分布', fontsize=title_size, fontweight='bold')
ax.set_xlabel('性别', fontsize=label_size)
ax.set_ylabel('订单数', fontsize=label_size)
ax.set_ylim(0, gender['count'].max() * 1.15)
# 子图2:销售额
ax = axes[1]
bars = ax.bar(gender['sex'], gender['sales'] / 10000.0, color=colors_gender, edgecolor='white')
for bar in bars:
height = bar.get_height()
ax.text(bar.get_x() + bar.get_width()/2, height + height*0.01,
f'{height:.1f}万',
ha='center', va='bottom', fontsize=annot_size)
ax.set_title('性别消费金额对比', fontsize=title_size, fontweight='bold')
ax.set_xlabel('性别', fontsize=label_size)
ax.set_ylabel('销售额(万元)', fontsize=label_size)
ax.set_ylim(0, (gender['sales'].max() / 10000.0) * 1.15)
plt.tight_layout()
save_path = os.path.join(CHARTS_DIR, '03_性别消费对比.png')
plt.savefig(save_path, dpi=300, bbox_inches='tight')
plt.close()
print(f"已保存:{save_path}")
# 4. 年龄分布——柱状图+折线图,分别标注订单数和销售额
fig, ax1 = plt.subplots(figsize=(12, 6))
age = RESULTS['age_analysis'].copy()
x_labels = age['age_group'].astype(str)
x_pos = np.arange(len(x_labels))
# 柱状图:订单数
bars = ax1.bar(x_pos, age['count'], color='#2ecc71', alpha=0.7, label='订单数', edgecolor='white')
for bar in bars:
height = bar.get_height()
ax1.text(bar.get_x() + bar.get_width()/2, height + height*0.01,
f'{int(height):,}',
ha='center', va='bottom', fontsize=annot_size)
ax1.set_xlabel('年龄分组', fontsize=label_size)
ax1.set_ylabel('订单数', fontsize=label_size, color='#2ecc71')
ax1.tick_params(axis='y', labelcolor='#2ecc71')
ax1.set_xticks(x_pos)
ax1.set_xticklabels(x_labels)
# 折线图:销售额
ax2 = ax1.twinx()
line = ax2.plot(x_pos, age['sales'] / 10000.0, color='#e74c3c', marker='o',
linewidth=2.5, markersize=8, label='销售额')
for i, v in enumerate(age['sales'] / 10000.0):
ax2.annotate(f'{v:.1f}万',
xy=(i, v),
xytext=(0, 12),
textcoords='offset points',
ha='center', fontsize=annot_size, color='#e74c3c')
ax2.set_ylabel('销售额(万元)', fontsize=label_size, color='#e74c3c')
ax2.tick_params(axis='y', labelcolor='#e74c3c')
# 合并图例
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper right')
ax1.set_title('年龄分布与消费金额', fontsize=title_size, fontweight='bold')
plt.tight_layout()
save_path = os.path.join(CHARTS_DIR, '04_年龄分布.png')
plt.savefig(save_path, dpi=300, bbox_inches='tight')
plt.close()
print(f"已保存:{save_path}")
# 5. 地区排行——水平条形图,条形右侧标注销售额
plt.figure(figsize=(11, 7))
local = RESULTS['local_top10'].sort_values('sales', ascending=True).copy()
colors_local = sns.color_palette("viridis", len(local))
bars = plt.barh(local['local'], local['sales'] / 10000.0, color=colors_local)
for bar, sales in zip(bars, local['sales']):
width = bar.get_width()
plt.text(width + 10, bar.get_y() + bar.get_height()/2,
f'{sales/10000:.1f}万',
va='center', ha='left', fontsize=annot_size)
plt.title('地区消费 Top 10', fontsize=title_size, fontweight='bold')
plt.xlabel('销售额(万元)', fontsize=label_size)
plt.ylabel('地区', fontsize=label_size)
plt.xlim(0, local['sales'].max() / 10000.0 * 1.25)
plt.tight_layout()
save_path = os.path.join(CHARTS_DIR, '05_地区排行.png')
plt.savefig(save_path, dpi=300, bbox_inches='tight')
plt.close()
print(f"已保存:{save_path}")
# 6. 价格分布——添加统计信息文本
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
p99 = df['price'].quantile(0.99)
display_prices = df[df['price'] <= p99]['price']
# 子图1:直方图
ax = axes[0]
n, bins, patches = ax.hist(display_prices, bins=50, color='#3498db',
edgecolor='white', alpha=0.75)
ax.set_title('价格分布直方图(≤99分位)', fontsize=title_size, fontweight='bold')
ax.set_xlabel('价格', fontsize=label_size)
ax.set_ylabel('频次', fontsize=label_size)
ax.axvline(display_prices.mean(), color='red', linestyle='--', linewidth=2, label=f'均值={display_prices.mean():.2f}')
ax.axvline(display_prices.median(), color='green', linestyle='--', linewidth=2, label=f'中位数={display_prices.median():.2f}')
ax.legend(fontsize=annot_size)
# 子图2:箱线图
ax = axes[1]
sns.boxplot(x=display_prices, color='#e74c3c', ax=ax)
ax.set_title('价格分布箱线图(≤99分位)', fontsize=title_size, fontweight='bold')
ax.set_xlabel('价格', fontsize=label_size)
# 在箱线图上方添加统计信息
stats_text = (
f"样本数:{len(display_prices):,}\n"
f"均值:{display_prices.mean():.2f}\n"
f"中位数:{display_prices.median():.2f}\n"
f"标准差:{display_prices.std():.2f}\n"
f"最大值(≤99分位):{display_prices.max():.2f}"
)
ax.text(0.98, 0.97, stats_text, transform=ax.transAxes,
fontsize=annot_size, verticalalignment='top', horizontalalignment='right',
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))
plt.tight_layout()
save_path = os.path.join(CHARTS_DIR, '06_价格分布.png')
plt.savefig(save_path, dpi=300, bbox_inches='tight')
plt.close()
print(f"已保存:{save_path}")
# ============================================================
# 步骤 5:深度分析(RFM、用户价值分层、复购率)
# ============================================================
def step5_advanced_analysis(df):
log_step("步骤 5:深度分析(RFM、用户价值分层、复购率)")
# 设定分析基准日期为数据集中的最大日期
max_date = df['event_time'].dt.date.max()
RESULTS['max_date'] = max_date
print(f"分析基准日期:{max_date}")
# 1. RFM 模型
rfm = df.groupby('user_id').agg({
'event_time': lambda x: (pd.Timestamp(max_date, tz='Asia/Shanghai') - x.max()).days,
'order_id': 'nunique',
'price': 'sum'
}).reset_index()
rfm.columns = ['user_id', 'Recency', 'Frequency', 'Monetary']
# RFM 评分(使用五分位)
rfm['R_score'] = pd.qcut(rfm['Recency'], 5, labels=[5, 4, 3, 2, 1])
rfm['F_score'] = pd.qcut(rfm['Frequency'].rank(method='first'), 5, labels=[1, 2, 3, 4, 5])
rfm['M_score'] = pd.qcut(rfm['Monetary'], 5, labels=[1, 2, 3, 4, 5])
# 简单分层规则
def rfm_segment(row):
r, f, m = int(row['R_score']), int(row['F_score']), int(row['M_score'])
if r >= 4 and f >= 4 and m >= 4:
return '重要价值客户'
elif r >= 4 and f >= 4:
return '重要保持客户'
elif r >= 4 and m >= 4:
return '重要发展客户'
elif f >= 4 and m >= 4:
return '重要挽留客户'
elif r >= 4:
return '新客户'
elif f >= 3 or m >= 3:
return '一般客户'
else:
return '低价值客户'
rfm['segment'] = rfm.apply(rfm_segment, axis=1)
segment_summary = rfm.groupby('segment').agg({
'user_id': 'count',
'Recency': 'mean',
'Frequency': 'mean',
'Monetary': 'mean'
}).round(2).reset_index()
segment_summary.columns = ['用户分层', '用户数', '平均最近一次购买天数', '平均购买频次', '平均消费金额']
RESULTS['rfm'] = rfm
RESULTS['segment_summary'] = segment_summary
print("\nRFM 用户价值分层:")
print(segment_summary.to_string(index=False))
# 1.1 绘制 RFM 用户分层图(第 7 张图表)
# 柱状图展示各层用户数,折线图展示各层平均消费金额
fig, ax1 = plt.subplots(figsize=(12, 6))
seg = segment_summary.copy()
x_pos = np.arange(len(seg))
# 柱状图:用户数,颜色按平均消费金额渐变
colors = sns.color_palette("viridis", len(seg))
bars = ax1.bar(x_pos, seg['用户数'], color=colors, alpha=0.8, label='用户数', edgecolor='white')
for bar in bars:
height = bar.get_height()
ax1.text(bar.get_x() + bar.get_width()/2, height + height*0.01,
f'{int(height):,}',
ha='center', va='bottom', fontsize=9)
ax1.set_xlabel('RFM 用户分层', fontsize=12)
ax1.set_ylabel('用户数(人)', fontsize=12, color='#2c5aa0')
ax1.tick_params(axis='y', labelcolor='#2c5aa0')
ax1.set_xticks(x_pos)
ax1.set_xticklabels(seg['用户分层'], rotation=20, ha='right')
# 折线图:平均消费金额(右轴)
ax2 = ax1.twinx()
line = ax2.plot(x_pos, seg['平均消费金额'], color='#e74c3c', marker='o',
linewidth=2.5, markersize=8, label='平均消费金额')
for i, v in enumerate(seg['平均消费金额']):
ax2.annotate(f'{v:,.0f}元',
xy=(i, v),
xytext=(0, 12),
textcoords='offset points',
ha='center', fontsize=9, color='#e74c3c')
ax2.set_ylabel('平均消费金额(元)', fontsize=12, color='#e74c3c')
ax2.tick_params(axis='y', labelcolor='#e74c3c')
# 右轴上限留余量,避免最高点标签被标题遮挡
ax2.set_ylim(0, seg['平均消费金额'].max() * 1.18)
# 合并图例
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper right')
ax1.set_title('RFM 用户分层:各层用户数与平均消费金额', fontsize=14, fontweight='bold')
plt.tight_layout()
save_path = os.path.join(CHARTS_DIR, '07_RFM用户分层.png')
plt.savefig(save_path, dpi=300, bbox_inches='tight')
plt.close()
print(f"已保存:{save_path}")
# 2. 复购率
# 定义复购用户:购买次数 >= 2 的用户
repurchase_users = rfm[rfm['Frequency'] >= 2].shape[0]
total_users = rfm.shape[0]
repurchase_rate = repurchase_users / total_users
RESULTS['repurchase_rate'] = repurchase_rate
print(f"\n复购率(购买次数≥2 的用户占比):{repurchase_rate:.2%} ({repurchase_users}/{total_users})")
# 3. 留存率(按首次购买月份 cohort)
# 取每个用户首次购买日期及后续活跃月份
user_first = df.groupby('user_id')['event_time'].min().reset_index()
user_first.columns = ['user_id', 'first_time']
user_first['first_month'] = user_first['first_time'].dt.to_period('M')
df_user = df.merge(user_first, on='user_id')
df_user['active_month'] = df_user['event_time'].dt.to_period('M')
df_user['period_num'] = (df_user['active_month'] - df_user['first_month']).apply(attrgetter('n'))
cohort = df_user.groupby(['first_month', 'period_num'])['user_id'].nunique().reset_index()
cohort_size = user_first.groupby('first_month')['user_id'].nunique().reset_index()
cohort_size.columns = ['first_month', 'cohort_size']
cohort = cohort.merge(cohort_size, on='first_month')
cohort['retention_rate'] = cohort['user_id'] / cohort['cohort_size']
cohort_pivot = cohort.pivot(index='first_month', columns='period_num', values='retention_rate')
RESULTS['cohort_pivot'] = cohort_pivot
print("\n留存率 cohort 表(行:首购月份,列:第 N 个月):")
print(cohort_pivot.round(4).to_string())
return df
# ============================================================
# 步骤 6:生成 Markdown 分析报告
# ============================================================
def step6_generate_report(df):
log_step("步骤 6:生成 Markdown 分析报告")
# 一些补充统计
avg_order_value_by_user = RESULTS['total_sales'] / RESULTS['unique_users']
top3_local = RESULTS['local_top10'].head(3)['local'].tolist()
report = f"""# 电商用户行为数据分析报告
## 1. 项目背景与目标
本报告基于 `电子产品销售分析.csv` 数据集,对电子产品的订单数据进行清洗、探索性分析、可视化呈现及深度用户价值分析。项目目标包括:
- 了解整体销售规模、时间趋势与用户画像;
- 识别数据质量问题并提出清洗方案;
- 通过 RFM 模型对用户进行价值分层;
- 基于分析结果给出可落地的业务建议。
## 2. 数据概况
### 2.1 数据来源
- 文件名:`电子产品销售分析.csv`
- 原始数据量:**{RESULTS['raw_shape'][0]:,}** 行 × **{RESULTS['raw_shape'][1]}** 列
- 清洗后数据量:**{RESULTS['clean_shape'][0]:,}** 行 × **{RESULTS['clean_shape'][1]}** 列
- 清洗过程中共移除 **{RESULTS['rows_removed']:,}** 行
### 2.2 字段说明
| 字段名 | 说明 | 清洗说明 |
| --- | --- | --- |
| event_time | 订单时间 | 已解析为 datetime,并提取 year/month/day/hour/date |
| order_id | 订单ID | 保留原值 |
| product_id | 产品ID | 保留原值 |
| category_id | 品类ID | 保留原值 |
| category_code | 品类编码 | 缺失率 {RESULTS['missing_raw'].loc['category_code', '缺失比例(%)']:.2f}%,拆分为 category_l1/l2/l3,缺失填“未知” |
| brand | 品牌 | 缺失填充为“未知品牌” |
| price | 价格 | 已删除 price ≤ 0 的异常记录 |
| user_id | 用户ID | 保留原值 |
| age | 用户年龄 | 已处理缺失并删除 <0 或 >100 的异常值 |
| sex | 用户性别 | 缺失填充为“未知” |
| local | 用户所在地区 | 缺失填充为“未知地区” |
### 2.3 数据清洗前后对比
- 原始重复行数:**{RESULTS['duplicate_count']:,}**
- 清洗后剩余:**{RESULTS['cleaned_rows']:,}**
- 新增字段:`year`, `month`, `day`, `hour`, `date`, `age_group`, `category_l1`, `category_l2`, `category_l3`, `category_code_missing`
## 3. 关键发现
### 3.1 总体指标
| 指标 | 数值 | 说明 |
| --- | --- | --- |
| 订单行数 | {RESULTS['order_lines']:,} | 原始记录数 |
| 独立订单数 | {RESULTS['total_orders']:,} | 去重后的订单数 |
| 总销售额 | {RESULTS['total_sales']:,.2f} | 所有订单金额合计 |
| 客单价 | {RESULTS['atv']:,.2f} | 总销售额 / 独立订单数 |
| 件单价 | {RESULTS['unit_price']:,.2f} | 总销售额 / 订单行数 |
| 独立用户数 | {RESULTS['unique_users']:,} | 去重后的用户数 |
| 人均消费 | {avg_order_value_by_user:,.2f} | 总销售额 / 独立用户数 |
### 3.2 时间趋势
销售额随时间呈现一定波动,详见下图:

### 3.3 品类维度
一级品类销售额占比如下图所示,头部品类对销售额贡献显著:

### 3.4 用户画像
#### 3.4.1 性别分布及消费

#### 3.4.2 年龄分布

#### 3.4.3 地区消费 Top 10
消费力最强的三个地区为:**{'、'.join(top3_local)}**。

### 3.5 价格分布
价格整体呈右偏分布,存在少量高价值订单:

## 4. 数据质量问题总结
1. **category_code 缺失率较高**:原始缺失率为 **{RESULTS['missing_raw'].loc['category_code', '缺失比例(%)']:.2f}%**,对品类分析影响较大,后续需完善商品类目录入流程。
2. **brand 缺失**:缺失率 **{RESULTS['missing_raw'].loc['brand', '缺失比例(%)']:.2f}%**,已填充为“未知品牌”。
3. **价格异常**:存在 price ≤ 0 的记录,已按异常值删除。
4. **年龄异常**:存在 age < 0 或 age > 100 的极端值,已删除。
5. **时间字段**:原始为字符串且带 UTC 时区,已统一转换为中国时区。
## 5. 深度分析
### 5.1 RFM 用户价值分层
基于最近一次消费(R)、消费频次(F)、消费金额(M)进行五分位评分,用户分层结果如下:
{RESULTS['segment_summary'].to_markdown(index=False)}
### 5.2 复购率
复购用户(购买次数 ≥ 2)占比为 **{RESULTS['repurchase_rate']:.2%}**。
### 5.3 留存率
按用户首购月份 cohort 统计的留存率(部分展示):
{RESULTS['cohort_pivot'].round(4).head().to_markdown()}
## 6. 业务建议
1. **优先补齐 category_code 数据**:品类缺失会影响商品推荐、库存管理和营销投放,建议从商品主数据源头治理。
2. **重点运营高价值客户**:对“重要价值客户”提供会员权益、专属客服和优先发货,提升忠诚度。
3. **激活沉默客户**:针对 R 分低但 F/M 分高的“重要挽留客户”进行短信/Push 召回。
4. **区域差异化运营**:对 Top 消费地区加大仓配投入和本地化营销;对低消费地区分析原因,挖掘增长潜力。
5. **提升复购率**:当前复购率为 {RESULTS['repurchase_rate']:.2%},可通过会员积分、复购券、搭配购等方式刺激二次购买。
6. **价格带优化**:结合价格分布,重点推广高转化价格带商品,同时用高客单品提升整体 GMV。
## 7. 分析局限性
1. 数据集仅包含订单行为数据,缺少浏览、加购、收藏等用户行为链路,无法完整还原转化漏斗。
2. category_code 缺失率较高,品类相关结论可能存在偏差。
3. 用户性别、年龄、地区字段未经验证,可能存在录入误差。
4. RFM 分层规则采用固定阈值,实际业务中应结合业务目标和 A/B 测试结果不断调优。
---
*报告生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*
"""
with open(REPORT_MD, 'w', encoding='utf-8') as f:
f.write(report)
print(f"报告已生成:{REPORT_MD}")
# ============================================================
# 步骤 7:文件归档
# ============================================================
def step7_archive(df):
log_step("步骤 7:文件归档")
# 保存清洗后的数据
df.to_csv(CLEANED_CSV, index=False, encoding='utf-8')
print(f"清洗数据已保存:{CLEANED_CSV}")
# 将当前脚本复制到 output 目录
script_src = 'analysis_script.py'
script_dst = os.path.join(OUTPUT_DIR, 'analysis_script.py')
shutil.copy2(script_src, script_dst)
print(f"分析脚本已复制:{script_dst}")
# 列出 output 目录结构
print("\n归档目录结构:")
for root, dirs, files in os.walk(OUTPUT_DIR):
level = root.replace(OUTPUT_DIR, '').count(os.sep)
indent = ' ' * 2 * level
print(f"{indent}{os.path.basename(root)}/")
subindent = ' ' * 2 * (level + 1)
for file in files:
file_path = os.path.join(root, file)
size = os.path.getsize(file_path)
print(f"{subindent}{file} ({size:,} bytes)")
# ============================================================
# 主程序
# ============================================================
def main():
# 导入 attrgetter(用于 cohort 计算)
global attrgetter
from operator import attrgetter
print("开始执行电商用户行为数据分析项目...")
ensure_dirs()
try:
df = step1_load_data()
df = step2_clean_data(df)
df = step3_eda(df)
step4_visualization(df)
df = step5_advanced_analysis(df)
step6_generate_report(df)
step7_archive(df)
print("\n" + "=" * 70)
print("所有步骤执行完毕,分析结果已归档至 ./output/ 目录。")
print("=" * 70)
except Exception as e:
print(f"\n执行过程中出现错误:{e}")
raise
if __name__ == '__main__':
main()