简介:这份资源是面向数据分析初学者与进阶学习者的伦敦住房市场分析项目,基于超过13000条真实记录,涵盖平均房价、房屋销售数量与犯罪率等关键指标,帮助读者理解房地产市场的动态与影响因素。压缩包共5个文件,约566KB,包含2个Jupyter Notebook、1个CSV数据集、1个Markdown说明文档和1个HTML页面,分别用于代码演示、数据存储、项目说明与结果展示。已有173人学习下载。项目完整覆盖数据加载与预处理、缺失值与异常值处理、基于matplotlib和seaborn的探索性数据分析、相关性热力图与聚类分析,以及线性回归、决策树、随机森林等房价预测建模与R²、MSE、MAE等指标评估,并涉及Plotly或Bokeh交互式仪表板与报告撰写。读者可借此掌握从数据清洗到建模评估的全流程Python实战技能,积累可复用的分析思路与代码模板。
1. 拿到一份伦敦住房数据集,先别急着画图
很多人拿到London-housing-data这类项目,第一反应是打开 Jupyter 直接df.head()然后开始画柱状图。我见过太多人这么干,结果跑到一半发现字段类型全是 object,价格列里混着£和逗号,日期列是字符串,最后花在清洗上的时间比分析还多。这个项目本质上是一套用 Python 做城市住房数据探索的完整流程——从原始 CSV 到清洗、特征构造、分组聚合、可视化,最后输出能支撑判断的结论。它适合两类人:一是刚学完 pandas 想找个真实数据集练手的,二是需要快速摸清一个城市住房市场结构的从业者。HTML 报告输出是它常见的交付形态之一,后面会专门讲怎么把分析结果落成可分享的页面。核心价值不在于代码多复杂,而在于它把「脏数据到可用结论」这条链路走通了。
2. 数据加载与字段清洗:把 £ 和逗号从价格列里剥出来
2.1 先看清数据长什么样再动手
伦敦住房数据集的典型结构,通常包含区域名(area)、房产类型(property_type)、卧室数(bedrooms)、价格(price)、成交日期(date)、邮编(postcode)等字段。不同来源的 CSV 编码和分隔符可能不一样,常见做法是先探测编码再读入,避免中文或特殊符号乱码。
import pandas as pd import numpy as np # 先探测文件编码,避免直接读入报 UnicodeDecodeError import chardet with open('london_housing.csv', 'rb') as f: raw = f.read(100000) encoding = chardet.detect(raw)['encoding'] print(f"探测到的编码: {encoding}") # 用探测结果读入,low_memory=False 防止混合类型警告 df = pd.read_csv('london_housing.csv', encoding=encoding, low_memory=False) print(df.shape) print(df.dtypes) print(df.head(10))这段代码的逻辑是:先用chardet对文件前 100KB 做编码探测,拿到编码后再传给read_csv。low_memory=False的作用是让 pandas 一次性读完再推断类型,避免分块读取时同一列被推断成不同类型。参数上,encoding不设的话默认 utf-8,遇到 latin-1 或 cp1252 就会炸。df.dtypes是必看的一步,如果 price 显示为 object,说明里面有非数字字符,下一步就得处理。
2.2 价格列的清洗是重头戏
伦敦房价数据里,价格字段经常长这样:£450,000、£1,200,000、甚至Price on application。直接astype(float)必翻车。我一般会写一个专门的清洗函数,把货币符号、千分位逗号、空格全部去掉,再把无法转换的标记为 NaN。
def clean_price(val): """ 清洗价格字段: - 去掉 £ 符号和千分位逗号 - 去掉首尾空格 - 无法转换的返回 np.nan """ if pd.isna(val): return np.nan val = str(val).replace('£', '').replace(',', '').strip() try: return float(val) except ValueError: return np.nan df['price_clean'] = df['price'].apply(clean_price) # 查看清洗后的缺失情况 missing_ratio = df['price_clean'].isna().mean() print(f"价格缺失比例: {missing_ratio:.2%}") # 缺失超过 30% 的列要考虑是否保留 threshold = 0.3 cols_to_drop = [c for c in df.columns if df[c].isna().mean() > threshold] print(f"建议丢弃的列: {cols_to_drop}")clean_price函数的核心是「先替换再转换」,replace链式调用把干扰字符去掉,try/except兜住那些文本型价格。missing_ratio用来判断这列还值不值得用——如果超过 30% 都是 NaN,后续分析结论的可信度就很低。cols_to_drop是一个通用的缺失列筛选逻辑,阈值 0.3 是我常用的经验值,你可以根据业务容忍度调整到 0.2 或 0.5。
2.3 日期和分类字段的类型修正
日期列如果是字符串,后续按时间聚合会非常别扭。常见做法是统一转成datetime64,同时把区域名、房产类型这类低基数字段转成category,能省内存也能加速 groupby。
# 日期转换,errors='coerce' 让无法解析的变成 NaT df['date'] = pd.to_datetime(df['date'], errors='coerce', dayfirst=True) # 分类字段转 category for col in ['area', 'property_type', 'postcode']: if col in df.columns: df[col] = df[col].astype('category') # 检查日期范围是否合理 print(df['date'].min(), df['date'].max()) print(df['date'].isna().sum())dayfirst=True是针对英式日期格式(日/月/年)的关键参数,不设的话 pandas 会按美式月/日/年解析,导致大量 NaT。errors='coerce'保证解析失败的不会抛异常而是变成 NaT,方便后续统计缺失量。转category的收益在数据量大时才明显,但养成习惯没坏处。
3. 特征构造与分组聚合:从原始字段里挖出可比较的指标
3.1 单价指标比总价更有分析价值
总价受户型面积影响太大,直接比较不同区域的均价容易得出误导性结论。我一般会构造「每卧室价格」或「每平方英尺价格」这类归一化指标。如果数据集里有面积字段就直接除,没有的话用卧室数做粗略代理。
# 构造每卧室单价,避免除以 0 df['price_per_bedroom'] = df.apply( lambda row: row['price_clean'] / row['bedrooms'] if pd.notna(row['price_clean']) and row['bedrooms'] > 0 else np.nan, axis=1 ) # 按区域聚合,同时算多个统计量 area_stats = df.groupby('area', observed=True).agg( median_price=('price_clean', 'median'), mean_price=('price_clean', 'mean'), median_ppb=('price_per_bedroom', 'median'), count=('price_clean', 'count') ).reset_index() # 过滤掉样本量太少的区域 area_stats = area_stats[area_stats['count'] >= 30] area_stats = area_stats.sort_values('median_ppb', ascending=False) print(area_stats.head(15))apply里用lambda做条件判断,确保卧室数为 0 或价格缺失时返回 NaN,避免除零错误。groupby的observed=True在 category 类型下能避免生成大量空组合。聚合时同时算中位数和均值,是因为房价分布通常右偏,均值容易被豪宅拉高,中位数更能反映典型水平。count >= 30是一个样本量门槛,低于这个数的区域统计结论不稳定,我一般会直接过滤掉。
3.2 时间维度上的价格趋势
如果日期字段跨度够大,按季度或年度聚合能看出市场走势。这里要注意的是,不同时期的成交结构可能不同,所以趋势分析最好固定房产类型或卧室数再做。
# 提取季度 df['quarter'] = df['date'].dt.to_period('Q') # 只看 2 卧室公寓的趋势 flat_2bed = df[(df['property_type'] == 'Flat') & (df['bedrooms'] == 2)] trend = flat_2bed.groupby('quarter', observed=True).agg( median_price=('price_clean', 'median'), volume=('price_clean', 'count') ).reset_index() trend['quarter'] = trend['quarter'].astype(str) print(trend.tail(12))dt.to_period('Q')把日期转成季度周期对象,比手动算季度更省事。筛选条件固定了房产类型和卧室数,这样趋势反映的是「同类房产」的价格变化,而不是成交结构变化带来的假象。volume列用来判断某个季度的结论是否可信——如果某季度只有 5 笔成交,中位数波动大是正常的。
3.3 交叉分析:区域和房产类型的组合
单看区域或单看类型都不够,真正有决策价值的是「某区域某类型房产」的表现。用pivot_table可以快速拉出一张交叉表。
pivot = pd.pivot_table( df, values='price_clean', index='area', columns='property_type', aggfunc='median' ) # 只保留样本量足够的区域 valid_areas = area_stats['area'].tolist() pivot = pivot.loc[pivot.index.isin(valid_areas)] # 按公寓价格排序 if 'Flat' in pivot.columns: pivot = pivot.sort_values('Flat', ascending=False) print(pivot.head(20))pivot_table默认用均值,这里显式指定aggfunc='median'更稳健。valid_areas复用了上一节的样本量过滤结果,保证交叉表里的区域都是有统计意义的。排序选Flat列是因为公寓通常是伦敦市场的主力交易类型,以它为基准比较符合实际。
4. 可视化与 HTML 报告输出:让结论能直接给人看
4.1 用 matplotlib 快速出图
分析结果如果只停留在 DataFrame 里,很难跟非技术同事沟通。我一般会先用 matplotlib 出几张核心图,确认数据形态没问题再考虑交互式方案。
import matplotlib.pyplot as plt import matplotlib # 设置中文字体,避免乱码 matplotlib.rcParams['font.sans-serif'] = ['SimHei'] matplotlib.rcParams['axes.unicode_minus'] = False fig, axes = plt.subplots(1, 2, figsize=(14, 6)) # 左图:区域中位单价 Top 15 top15 = area_stats.head(15) axes[0].barh(top15['area'], top15['median_ppb'], color='steelblue') axes[0].set_xlabel('Median Price per Bedroom (£)') axes[0].set_title('Top 15 Areas by Median Price per Bedroom') axes[0].invert_yaxis() # 右图:2 卧室公寓季度趋势 axes[1].plot(trend['quarter'], trend['median_price'], marker='o', color='coral') axes[1].set_xlabel('Quarter') axes[1].set_ylabel('Median Price (£)') axes[1].set_title('2-Bed Flat Price Trend') axes[1].tick_params(axis='x', rotation=45) plt.tight_layout() plt.savefig('london_housing_overview.png', dpi=150) plt.show()barh画横向条形图,区域名不会挤在一起。invert_yaxis()让排名最高的区域显示在顶部,符合阅读习惯。tick_params(rotation=45)解决季度标签重叠问题。dpi=150保证保存的图片在报告里够清晰。中文字体设置是必须的,否则标题里的中文会变成方框。
4.2 生成 HTML 报告
HTML 输出是这类项目常见的交付形式,好处是能嵌表格、嵌图片、加样式,发给人直接浏览器打开就能看。常见做法是用pandas的to_html配合模板字符串拼一个简单页面。
# 把关键表格转成 HTML area_table_html = area_stats.head(20).to_html( index=False, classes='table table-striped', float_format='%.0f' ) trend_table_html = trend.tail(8).to_html( index=False, classes='table', float_format='%.0f' ) html_template = f""" <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>London Housing Analysis</title> <style> body {{ font-family: Arial, sans-serif; margin: 40px; }} table {{ border-collapse: collapse; width: 100%; }} th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }} th {{ background-color: #f2f2f2; }} img {{ max-width: 100%; height: auto; }} </style> </head> <body> <h1>London Housing Data Analysis</h1> <h2>Overview</h2> <img src="london_housing_overview.png" alt="Overview Charts"> <h2>Top Areas by Price per Bedroom</h2> {area_table_html} <h2>Recent Quarterly Trend (2-Bed Flat)</h2> {trend_table_html} </body> </html> """ with open('london_housing_report.html', 'w', encoding='utf-8') as f: f.write(html_template) print("报告已生成: london_housing_report.html")to_html的classes参数给表格加上 CSS 类名,配合<style>里的定义就能有基本样式。float_format='%.0f'让价格不显示小数,读起来更干净。图片用相对路径引用,保证 HTML 文件和图片在同一目录下能正常显示。整个模板就是一个 f-string,没有引入额外依赖,适合快速交付。如果要更复杂的报告,可以换成jinja2模板,但那是另一个话题了。
5. 避坑与排查:这几个地方我翻过车
5.1 价格清洗后没检查量级,中位数算出来是错的
现象:清洗完价格直接 groupby,发现某区域中位数只有几百英镑。原因:部分价格字段单位是「千英镑」或者清洗时把小数点也去掉了。解决:清洗后先看describe(),确认 min、max、中位数的量级是否合理,异常值单独拉出来看原始值。
5.2 日期解析用了默认参数,一半数据变成 NaT
现象:pd.to_datetime之后isna().sum()占了快一半。原因:英式日期是日/月/年,默认按美式解析,13 以上的「月份」全部失败。解决:加dayfirst=True,并且解析后检查日期范围是否落在合理区间(比如 2000 到当前年份)。
5.3 groupby 之后忘了 reset_index,后续取列报 KeyError
现象:area_stats['area']报 KeyError。原因:groupby 后区域名变成了索引,不是列。解决:聚合时加.reset_index(),或者用.loc按索引取。我一般习惯直接 reset,省得后面记不清。
5.4 category 类型做 groupby 出现大量空组合
现象:groupby 结果里出现很多 count 为 0 的行。原因:category 类型会保留所有类别,即使某些类别在当前数据里没有出现。解决:groupby 时加observed=True,只保留实际出现的组合。
5.5 HTML 报告里的图片路径用了绝对路径,换台机器就裂了
现象:本地打开报告图片正常,发给别人图片全挂。原因:<img src>写的是本地绝对路径。解决:统一用相对路径,并且把 HTML 和图片放在同一个目录下打包发送。如果图片多,可以转成 base64 内嵌,但文件会变大。
6. 进阶技巧:把分析流程封装成可复用的管道
走到这里,单次分析已经能跑通了。但如果你需要反复跑不同月份的数据,或者换一个城市的数据集,把清洗和聚合逻辑封装成函数会省很多事。我一般会写一个build_report函数,输入原始 CSV 路径,输出 HTML 报告路径,中间所有步骤串起来。
def build_report(input_csv, output_html, min_samples=30): """ 一键生成住房数据 HTML 报告 input_csv: 原始 CSV 路径 output_html: 输出 HTML 路径 min_samples: 区域最小样本量门槛 """ # 1. 加载 df = pd.read_csv(input_csv, low_memory=False) # 2. 清洗价格 df['price_clean'] = df['price'].apply(clean_price) # 3. 日期解析 df['date'] = pd.to_datetime(df['date'], errors='coerce', dayfirst=True) # 4. 构造单价 df['price_per_bedroom'] = df.apply( lambda r: r['price_clean'] / r['bedrooms'] if pd.notna(r['price_clean']) and r['bedrooms'] > 0 else np.nan, axis=1 ) # 5. 聚合 stats = df.groupby('area', observed=True).agg( median_price=('price_clean', 'median'), median_ppb=('price_per_bedroom', 'median'), count=('price_clean', 'count') ).reset_index() stats = stats[stats['count'] >= min_samples] # 6. 输出 HTML table_html = stats.sort_values('median_ppb', ascending=False).head(20).to_html( index=False, float_format='%.0f' ) html = f"<html><head><meta charset='utf-8'></head><body>" html += f"<h1>Housing Report</h1>{table_html}</body></html>" with open(output_html, 'w', encoding='utf-8') as f: f.write(html) return output_html # 调用 build_report('london_housing.csv', 'report_v2.html')这个函数的参数设计有几个考虑:min_samples暴露出来是为了不同数据集能调整,伦敦数据用 30 合适,小城市可能得降到 10。clean_price是外部定义的函数,封装时要么一起打包,要么改成内部定义。返回输出路径而不是 DataFrame,是为了让调用方直接拿到可交付的文件。
验证封装是否正确的办法很简单:拿同一份数据分别跑手动流程和build_report,对比输出的中位数和样本量是否一致。我一般会抽三个区域手工核对,确认没问题再拿去跑批量。
从那以后我每次封装完管道,都会先用一份小样本数据跑一遍全流程,确认中间没有类型报错、没有意外的 NaN 扩散,再上全量数据。这个习惯帮我省过好几次「跑了两小时才发现日期解析全错」的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取