名称: 数据新闻学 描述: 数据新闻学工作流程,用于分析、可视化和故事讲述。适用于分析数据集、创建图表和地图、清理混乱数据、计算统计数据或构建数据驱动故事。对于处理定量信息的记者、新闻编辑室和研究人员至关重要。
数据新闻学方法论
系统化方法,用于新闻学中数据的发现、分析和呈现。
数据新闻学故事结构
数据新闻学框架
数据新闻学框架由Philip Meyer建立,他是Knight-Ridder的记者、哈佛Nieman研究员和UNC-Chapel Hill的教授。在他的书《新精确新闻学》中,概述了他的理念,Meyer鼓励记者将新闻学“视为一门科学”,通过采用科学方法:
- 进行观察/提出问题
- 研究问题/收集、存储和检索数据
- 形成假设
- 测试假设,使用定性(采访、文档等)和定量(数据分析等)方法
- 分析结果,将其减少到最重要的发现
- 呈现给受众
这个过程应被视为迭代的,而不是顺序的。
## 数据故事弧
### 1. 钩子(核心段落)
- 关键发现是什么?
- 为什么读者应该关心?
- 人类影响是什么?
### 2. 证据
- 展示数据
- 解释方法论
- 承认局限性
### 3. 上下文
- 这与过去相比如何?
- 这与其他地方相比如何?
- 趋势是什么?
### 4. 人类元素
- 个体示例,说明数据
- 专家解释
- 受影响的声音
### 5. 意义
- 这对未来意味着什么?
- 哪些问题仍然存在?
- 可能产生什么行动?
### 6. 方法论框
- 数据来自哪里?
- 如何分析的?
- 局限性是什么?
- 读者如何进一步探索?
方法论文档模板
## 我们如何进行分析
### 数据来源
[列出所有数据来源,包含链接和访问日期]
### 时间周期
[指定确切覆盖的时间周期]
### 定义
[定义关键术语以及如何操作化它们]
### 分析步骤
1. [第一步分析]
2. [第二步]
3. [继续...]
### 局限性
- [局限性1]
- [局限性2]
### 我们排除了什么及原因
- [排除类别]: [原因]
### 验证
[如何验证/检查发现]
### 代码和数据可用性
[如果共享代码/数据,链接到GitHub仓库]
### 联系
[读者如何联系您提出问题]
数据获取
公共数据来源
## 联邦数据来源
### 一般
- Data.gov - 联邦开放数据门户
- 人口普查局 (census.gov) - 人口统计、经济数据
- BLS (bls.gov) - 就业、通货膨胀、工资
- BEA (bea.gov) - GDP、经济账户
- 联邦储备 (federalreserve.gov) - 金融数据
- SEC EDGAR - 公司文件
### 特定领域
- EPA (epa.gov/data) - 环境数据
- FDA (fda.gov/data) - 药物批准、召回、不良事件
- CDC WONDER - 健康统计
- NHTSA - 车辆安全数据
- DOT - 交通统计
- FEC - 竞选资金
- USASpending.gov - 联邦合同和赠款
### 州和地方
- 州开放数据门户(搜索:“[州] open data”)
- Socrata-powered网站(许多城市/州)
- OpenStreets,市政GIS门户
- 州审计长/审计员报告
数据请求策略
## 获取非公开数据
### 公共记录请求(如FOIA)用于数据集
- 请求数据库,而不仅仅是文档
- 请求数据字典/模式
- 请求原生格式(CSV、SQL转储)
- 指定字段级需求
### 构建自己的数据集
- 抓取公共信息
- 从读者众包
- 系统化文档审查
- 调查(使用正确方法论)
### 商业数据来源(针对新闻编辑室)
- LexisNexis
- Refinitiv
- Bloomberg
- 行业特定数据库
数据清洗和准备
常见数据问题
from typing import Any
import pandas as pd
import numpy as np
from rapidfuzz import fuzz
from itertools import combinations
# 通胀调整
import cpi
import wbdata
def standardize_name(name: Any) -> str | None:
"""标准化名称格式为'名 姓'。"""
if pd.isna(name):
return None
name = str(name).strip().upper()
# 处理“姓, 名”格式
if ',' in name:
parts = name.split(',')
name = f"{parts[1].strip()} {parts[0].strip()}"
return name
def parse_date(date_str: Any) -> pd.Timestamp | None:
"""解析各种格式的日期。"""
if pd.isna(date_str):
return None
formats = [
'%m/%d/%Y', '%Y-%m-%d', '%B %d, %Y',
'%d-%b-%y', '%m-%d-%Y', '%Y/%m/%d'
]
for fmt in formats:
try:
return pd.to_datetime(date_str, format=fmt)
except:
continue
# 回退到pandas解析器
try:
return pd.to_datetime(date_str)
except:
return None
def handle_missing(df:pd.DataFrame, thresh:int | None, per_thresh:float | None, required_col:str | None) -> pd.DataFrame:
'''处理具有太多缺失值的DataFrame,由用户定义。'''
if thresh and data_clean.isna().sum() >= thresh:
return df.dropna(subset=[required_col]).reset_index(drop=True).copy()
elif per_thresh and (data_clean.isna().sum() / len(data_clean) * 100) >= per_thresh:
return df.dropna(subset=[required_col]).reset_index(drop=True).copy()
else:
return df
def handle_duplicates(df:pd.DataFrame, thresh=int | None)
'''处理数据的重复行。'''
if thresh and df.duplicated().sum() >= thresh:
return df.drop_duplicates().reset_index(drop=True).copy()
else:
return df
def flag_similar_names(df: pd.DataFrame, name_col: str, threshold: int = 85) -> pd.DataFrame:
"""标记具有潜在重复名称的行,使用向量化比较。"""
names = df[name_col].dropna().unique()
# 使用combinations()避免嵌套循环和重复比较
dup_names: set[Any] = {
name
for name1, name2 in combinations(names, 2)
if fuzz.ratio(str(name1).lower(), str(name2).lower()) >= threshold
for name in (name1, name2)
}
df['has_similar_name'] = df[name_col].isin(dup_names)
return df
def flag_outliers(series: pd.Series, method: str = 'iqr', threshold: float = 1.5) -> pd.Series:
"""标记统计异常值。"""
if method == 'iqr':
Q1 = series.quantile(0.25)
Q3 = series.quantile(0.75)
IQR = Q3 - Q1
lower = Q1 - threshold * IQR
upper = Q3 + threshold * IQR
return (series < lower) | (series > upper)
elif method == 'zscore':
z_scores = np.abs((series - series.mean()) / series.std())
return z_scores > threshold
# 使用描述性变量名并链式方法
data_clean = (pd
# 加载混乱数据 — raw_data是占位符
# 确保对文件类型使用正确的读取器
.read_csv('..data/raw/raw_data.csv')
# 数据类型校正
# 确保分析的正确类型
.assign(# 转换为数值(处理错误)
amount = lambda x: pd.to_numeric(x['amount'], errors='coerce'),
# 转换为分类(节省内存,启用排序)
status = lambda x: pd.Categorical(x['status']))
.assign(
# 不一致格式
# 问题:名称在不同格式中
# 如“SMITH, JOHN” vs “John Smith” vs “smith john”
name_clean = lambda x: standardize_name(x['name']),
# 日期不一致
# 问题:日期在多种格式中
# 如“01/15/2024”、“2024-01-15”、“January 15, 2024”、“15-Jan-24”
parse_date = lambda x: parse_date(x['date']),
# 异常值
# 识别潜在数据输入错误
amount_outlier = lambda x: flag_outliers(x['amount']),
)
# 模糊重复(相似但不相同)
# 使用记录链接或手动审查
.pipe(find_similar_names, name_col='name_clean', threshold=85)
# 缺失值
# 策略取决于上下文
# 首先检查缺失值模式
.pipe(handle_missing, thresh=None, per_thresh=None)
# 重复 — 查找和处理重复
.pipe(handle_duplicates, thresh=None)
.reset_index(drop=True)
.copy())
数据验证清单
## 预分析数据验证
### 结构检查
- [ ] 行计数匹配预期
- [ ] 列计数和名称正确
- [ ] 数据类型适当
- [ ] 没有意外的空列
### 内容检查
- [ ] 日期范围合理
- [ ] 数值在预期范围内
- [ ] 分类值匹配预期选项
- [ ] 地理数据正确解析
- [ ] ID在预期处唯一
### 一致性检查
- [ ] 总计加到预期值
- [ ] 交叉表平衡
- [ ] 相关字段一致
- [ ] 时间序列连续
### 来源验证
- [ ] 可追溯到原始来源
- [ ] 方法论已记录
- [ ] 已知局限性已注明
- [ ] 更新频率已理解
新闻学统计分析
带上下文的基本统计
# 任何数据集的基本统计
def describe_for_journalism(df: pd.DataFrame, col: str) -> pd.DataFrame:
"""生成记者友好的统计。"""
stats = df[col].describe(percentiles=[0.25, 0.5, 0.75, 0.9, 0.99])
# 将偏度添加到describe()输出
stats['skewness'] = df[col].skew()
return stats.to_frame(name=col)
# 示例解释
stats = describe_for_journalism(salaries, 'salary')
print(f"""
分析
---------------
我们分析了{stats['count']:,}个工资记录。
中位数工资为${stats['median']:,.0f},意味着一半工人
赚得更多,一半赚得更少。
平均工资为${stats['mean']:,.0f},这
比中位数{'高' if stats['mean'] > stats['median'] else '低'},
表明分布是{'右偏(被高收入者拉高)'
if stats['skewness'] > 0 else '左偏'}。
收入最高的10%至少赚${stats['90th_percentile']:,.0f}。
收入最高的1%至少赚${stats['99th_percentile']:,.0f}。
""")
比较和上下文
# 计算列的变更指标
def calculate_change(df: pd.DataFrame, col: str, periods: int = 1) -> pd.DataFrame:
"""使用内置pandas方法向DataFrame添加变更指标。
参数:
df: 输入DataFrame
col: 计算变更的列
periods: 回顾的行数(1=前一行,12=月度数据的逐年对比)
"""
return df.assign(
absolute_change=df[col].diff(periods),
percent_change=df[col].pct_change(periods) * 100,
direction=np.sign(df[col].diff(periods)).map({1: '增加', -1: '减少', 0: '不变'})
)
# 用法:
# changes = data_clean.pipe(calculate_change, 'revenue', periods=12) # 月度数据的逐年对比
# 人均计算(公平比较的关键)
def per_capita(value: float, population: float, multiplier: int = 100000) -> float:
"""计算人均率。"""
return (value / population) * multiplier # 每100,000是标准
# 示例:犯罪率
city_a = {'crimes': 5000, 'population': 100000}
city_b = {'crimes': 8000, 'population': 500000}
rate_a = per_capita(city_a['crimes'], city_a['population'])
rate_b = per_capita(city_b['crimes'], city_b['population'])
print(f"城市A: {rate_a:.1f} 每100,000居民的犯罪数")
print(f"城市B: {rate_b:.1f} 每100,000居民的犯罪数")
# 尽管总犯罪数较少,城市A实际上犯罪率更高!
def adjust_for_inflation(
amount: float | pd.Series,
from_year: int | pd.Series,
to_year: int,
country: str = 'US'
) -> float | pd.Series:
"""调整美元金额以考虑通胀。适用于标量或Series的.assign()。
参数:
amount: 要调整的值
from_year: 金额的原始年份
to_year: 调整的目标年份
country: ISO 2字母国家代码(默认'US')。美国使用BLS数据通过cpi包,
其他国家使用世界银行CPI数据(FP.CPI.TOTL指标)
"""
if country == 'US':
# 使用cpi包(更准确,来自BLS)
if isinstance(from_year, pd.Series):
return pd.Series([cpi.inflate(amt, yr, to=to_year)
for amt, yr in zip(amount, from_year)], index=amount.index)
return cpi.inflate(amount, from_year, to=to_year)
else:
# 使用世界银行数据于其他国家
cpi_data = wbdata.get_dataframe(
{'FP.CPI.TOTL': 'cpi'},
country=country
)['cpi'].to_dict()
from_cpi = pd.Series(from_year).map(cpi_data) if isinstance(from_year, pd.Series) else cpi_data[from_year]
to_cpi = cpi_data[to_year]
return amount * (to_cpi / from_cpi)
# 用法:
# adjust_for_inflation(100, 2020, 2024) # 默认美国
# adjust_for_inflation(100, 2020, 2024, country='GB') # 英国
# df.assign(inf_adjust24=lambda x: adjust_for_inflation(x['amount'], x['year'], 2024, country='DE'))
# 比较跨年美元时,始终调整!
相关性 vs 因果性
## 负责任地报告相关性
### 你可以说什么
- “X和Y相关”
- “当X增加时,Y倾向于增加”
- “X较高的区域也倾向于有较高的Y”
- “X与Y关联”
### 你不能说什么(没有更多证据)
- “X导致Y”
- “X导致Y”
- “Y发生是因为X”
### 暗示因果性前要问的问题
1. 是否有合理的机制?
2. 时间线是否合理(原因在效果之前)?
3. 是否有剂量-反应关系?
4. 发现是否已被复制?
5. 是否控制了混杂变量?
6. 是否有替代解释?
### 虚假相关的红旗
- 与无关事物的极高相关性(r > 0.95)
- 变量之间没有逻辑连接
- 第三变量可能解释两者
- 小样本大小和高方差
数据可视化
图表选择指南
## 选择正确的图表
### 比较
- **条形图**: 比较类别
- **分组条形图**: 跨组比较类别
- **子弹图**: 实际 vs 目标
### 随时间变化
- **折线图**: 时间趋势
- **面积图**: 随时间累积总计
- **斜率图**: 两点间变化
### 分布
- **直方图**: 单变量分布
- **箱线图**: 跨组比较分布
- **小提琴图**: 详细的分布形状
### 关系
- **散点图**: 两变量关系
- **气泡图**: 三变量(x, y, 大小)
- **连接散点图**: 随时间的关系变化
### 组成
- **饼图**: 整体部分(几乎从不使用,最多5片,优先选择环图)
- **环图**: 整体部分
- **堆叠条形图**: 跨类别的整体部分
- **树状图**: 层次组成
### 地理
- **等值线图**: 按区域的值(使用标准化数据!)
- **点图**: 个体位置
- **比例符号图**: 位置的量级
使用Plotly Express进行探索性交互可视化
import plotly.express as px
# 为所有图表设置默认模板
px.defaults.template = 'simple_white'
def create_bar_chart(
data: pd.DataFrame,
title: str,
source: str,
desc: str = '',
x_val: str,
y_val: str,
x_lab: str | None,
y_lab: str | None
) -> px.bar:
"""创建条形图。"""
fig = px.bar(
data,
x=x_val,
y=y_val,
text=desc,
title=title,
labels={'category': (x_lab if x_lab else x_val), 'value': (y_lab if y_lab else y_val)}
)
return fig
# 示例
fig = create_bar_chart(
data,
title='年度小部件生产',
source='小部件部门, 2024',
desc='小部件部门从2014年开始大幅增加生产。',
x_val='year',
y_val='widgets_prod',
x_lab='年份',
y_label='生产单位'
)
fig.show() # 交互显示
使用Datawrapper进行出版就绪的自动化数据可视化
import pandas as pd
import datawrapper as dw
# 认证:设置DATAWRAPPER_ACCESS_TOKEN环境变量,
# 或从文件读取并传递给create()
with open('datawrapper_api_key.txt', 'r') as f:
api_key = f.read().strip()
# 读入您的数据
data = pd.read_csv('../data/raw/data.csv')
# 使用新OOP API创建条形图
chart = dw.BarChart(
title='我的条形图标题',
intro='副标题或描述文本',
data=data,
# 格式化选项
value_label_format=dw.NumberFormat.ONE_DECIMAL,
show_value_labels=True,
value_label_alignment='left',
sort_bars=True, # 按值排序
reverse_order=False,
# 来源归属
source_name='您的数据来源',
source_url='https://example.com',
byline='您的姓名',
# 可选:自定义基本颜色
base_color='#1d81a2'
)
# 创建和发布(使用DATAWRAPPER_ACCESS_TOKEN环境变量,或传递token)
chart.create(access_token=api_key)
chart.publish()
# 获取图表URL和嵌入代码
print(f"图表ID: {chart.chart_id}")
print(f"图表URL: https://datawrapper.dwcdn.net/{chart.chart_id}")
iframe_code = chart.get_iframe_code(responsive=True)
# 用新数据更新现有图表(用于实时更新图表)
existing_chart = dw.get_chart('YOUR_CHART_ID') # 按ID检索
existing_chart.data = new_df # 分配新DataFrame
existing_chart.title = '更新标题' # 修改属性
existing_chart.update() # 推送更改到Datawrapper
existing_chart.publish() # 重新发布以使其生效
# 可选 — 导出图表为图像
chart.export(filepath='chart.png', width=800, height=600)
#查看图表
chart
避免误导性可视化
## 图表完整性清单
### 轴
- [ ] Y轴从零开始(对于条形图)
- [ ] 轴标签清晰
- [ ] 比例适当(未截断以夸大)
- [ ] 两个轴都标有单位
### 数据表示
- [ ] 所有数据点可见
- [ ] 颜色可区分(包括色盲)
- [ ] 比例准确
- [ ] 3D效果不扭曲感知
### 上下文
- [ ] 标题描述显示内容,而非结论
- [ ] 时间周期明确说明
- [ ] 来源引用
- [ ] 样本大小/方法论如有相关则注明
- [ ] 不确定性在适当处显示
### 诚实
- [ ] 避免挑选日期
- [ ] 异常值被解释,而非隐藏
- [ ] 双轴被证明合理(通常避免)
- [ ] 注释不误导
处理地理空间数据
地理编码数据
美国人口普查地理编码器
最适合: 仅美国地址。返回人口普查地理(区、块、FIPS代码)以及坐标—对于与人口普查人口统计数据的连接至关重要。
优点: 完全免费,无需API密钥。返回人口普查地理(州/县FIPS、区、块),让您与ACS/十年人口普查数据连接。对于标准美国地址匹配率好。
缺点: 每批限制10,000个地址。仅美国地址。比商业替代品慢。对于非标准地址(邮政信箱、农村路线、新建建筑)匹配率较低。
使用时: 您需要地理编码格式良好的美国地址,或者没有预算使用付费服务。
# pip install censusbatchgeocoder
import censusbatchgeocoder
import pandas as pd
# DataFrame必须有列: id, address, city, state, zipcode
# (state和zipcode可选但提高匹配率)
def census_geocode(
df: pd.DataFrame,
id_col: str = 'id',
address_col: str = 'address',
city_col: str = 'city',
state_col: str = 'state',
zipcode_col: str = 'zipcode',
chunk_size: int = 9999
) -> pd.DataFrame:
"""
使用美国人口普查批处理地理编码器地理编码DataFrame。
通过分块自动处理大于10,000行的数据集。
返回带有经纬度、州FIPS、县FIPS、区、块、是否匹配、是否精确、返回地址、地理编码地址的DataFrame。
"""
# 重命名列到预期格式
col_map = {id_col: 'id', address_col: 'address', city_col: 'city'}
if state_col and state_col in df.columns:
col_map[state_col] = 'state'
if zipcode_col and zipcode_col in df.columns:
col_map[zipcode_col] = 'zipcode'
renamed_df = df.rename(columns=col_map)
records = renamed_df.to_dict('records')
# 小数据集:直接地理编码
if len(records) <= chunk_size:
results = censusbatchgeocoder.geocode(records)
return pd.DataFrame(results)
# 大数据集:分块处理以保持在10,000限制下
all_results = []
for i in range(0, len(records), chunk_size):
chunk = records[i:i + chunk_size]
print(f"地理编码行 {i:,} 到 {i + len(chunk):,} of {len(records):,}...")
try:
results = censusbatchgeocoder.geocode(chunk)
all_results.extend(results)
except Exception as e:
print(f"从{i}开始的块错误: {e}")
for record in chunk:
all_results.append({**record, 'is_match': 'No_Match', 'latitude': None, 'longitude': None})
return pd.DataFrame(all_results)
# 用法:
geocoded = (pd
.read_csv('../data/raw/addresses.csv')
.assign(id=lambda x: x.index)
.pipe(census_geocode,
id_col='id',
address_col='street',
city_col='city',
state_col='state',
zipcode_col='zip'))
Google Maps地理编码器
最适合: 国际地址、高匹配率,以及混乱/非标准地址格式。
优点: 优秀匹配率,即使对于格式化不良的地址。全球适用。快速可靠。返回丰富元数据(地点类型、地址组件、地点ID)。
缺点: 需要付费(免费层后每1,000请求$5)。需要API密钥和计费账户。不返回人口普查地理—您需要进行单独的空间连接。
使用时: 您需要地理编码国际地址、有混乱地址数据无法通过人口普查地理编码器匹配,或者需要最高可能匹配率并有预算。
import googlemaps
from typing import Optional
def geocode_address_google(address: str, api_key: str) -> Optional[dict]:
"""
使用Google Maps API地理编码地址。
需要启用Geocoding API的API密钥。
"""
gmaps = googlemaps.Client(key=api_key)
result = gmaps.geocode(address)
if result:
location = result[0]['geometry']['location']
return {
'formatted_address': result[0]['formatted_address'],
'lat': location['lat'],
'lon': location['lng'],
'place_id': result[0]['place_id']
}
return None
# 批处理地理编码DataFrame
def batch_geocode(df: pd.DataFrame, address_col: str, api_key: str) -> pd.DataFrame:
gmaps = googlemaps.Client(key=api_key)
results = []
for address in df[address_col]:
try:
result = gmaps.geocode(address)
if result:
loc = result[0]['geometry']['location']
results.append({'lat': loc['lat'], 'lon': loc['lng']})
else:
results.append({'lat': None, 'lon': None})
except Exception:
results.append({'lat': None, 'lon': None})
return pd.concat([df, pd.DataFrame(results)], axis=1)
Geopandas
import geopandas as gpd
import pandas as pd
from shapely.geometry import Point
# 从各种格式读取数据
gdf = gpd.read_file('data.geojson') # GeoJSON
gdf = gpd.read_file('data.shp') # Shapefile
gdf = gpd.read_file('https://example.com/data.geojson') # 从URL
gdf = gpd.read_parquet('data.parquet') # GeoParquet(快速!)
# 将带有经纬度的DataFrame转换为GeoDataFrame
df = pd.read_csv('locations.csv')
geometry = [Point(xy) for xy in zip(df['longitude'], df['latitude'])]
gdf = gpd.GeoDataFrame(df, geometry=geometry)
# 设置CRS(坐标系)
# EPSG:4326 = WGS84(标准纬度、经度)
gdf = gdf.set_crs('EPSG:4326')
# 转换到不同CRS(用于面积/距离计算,使用投影CRS)
gdf_projected = gdf.to_crs('EPSG:3857') # Web Mercator,用于米制距离
# 基本空间操作
#查找形状面积
gdf['area'] = gdf_projected.geometry.area
#查找形状中心
gdf['centroid'] = gdf.geometry.centroid
#绘制点周围1km边界
gdf['buffer_1km'] = gdf_projected.geometry.buffer(1000) #当设置为CRS 3857
# 空间连接:查找多边形内的点
points = gpd.read_file('points.geojson')
polygons = gpd.read_file('boundaries.geojson')
joined = gpd.sjoin(points, polygons, predicate='within')
# 溶解:按属性合并几何
dissolved = gdf.dissolve(by='state', aggfunc='sum')
# 导出到各种格式
gdf.to_parquet('output.parquet') # GeoParquet(推荐)
gdf.to_file('output.geojson', driver='GeoJSON') #用于不支持GeoParquet的工具
使用.explore()、lonboard和Datawrapper进行地理可视化
.explore()
最适合: 数据分析和原型设计期间的快速探索。
优点: 内置在GeoPandas中—方法可用于任何GeoDataFrame。非常适合探索性数据分析—检查数据是否正确,探索空间模式,快速迭代地图设计。
缺点: 大数据集(>100k特征)变慢。与专用映射库相比自定义有限。需要额外依赖安装。
使用时: 您在分析过程中,想快速可视化GeoDataFrame而无需切换工具。
所需依赖:
pip install folium mapclassify matplotlib
folium- 对于.explore()工作必需(渲染交互地图)mapclassify- 使用scheme=参数进行分类时必需(例如’naturalbreaks’、‘quantiles’、‘equalinterval’)matplotlib- 对于颜色映射(cmap=)支持必需
import geopandas as gpd
# folium、mapclassify和matplotlib必须安装但无需导入
# geopandas在调用.explore()时自动导入它们
# 基本交互地图(使用folium引擎)
gdf.explore()
# 自定义等值线图
# (需要mapclassify用于scheme参数)
gdf.explore(
column='population', # 颜色刻度列
cmap='YlOrRd', # Matplotlib颜色映射
scheme='naturalbreaks', # 分类方案(需要mapclassify)
k=5, # 箱数
legend=True,
tooltip=['name', 'population'], # 悬停显示的列
popup=True, # 点击显示所有列
tiles='CartoDB positron', # 背景瓦片
style_kwds={'color': 'black', 'weight': 0.5} # 边框样式
)
lonboard
最适合: 大数据集和Jupyter笔记本中的高性能可视化。
优点: 通过deck.gl的GPU加速渲染,可平滑处理数百万点。优秀交互性—平移、缩放和悬停即使大数据集也流畅。原生支持GeoArrow格式以高效数据传输。
缺点: 需要单独安装(pip install lonboard)。样式选项更技术性(RGBA数组、deck.gl约定)。
使用时: 您有大点数据集(犯罪事件、传感器读数、业务位置)或需要100k+特征的流畅交互性。
import geopandas as gpd
from lonboard import viz, Map, ScatterplotLayer, PolygonLayer
# 快速可视化(自动检测几何类型)
viz(gdf)
# 点的自定义ScatterplotLayer
layer = ScatterplotLayer.from_geopandas(
gdf,
get_radius=100,
get_fill_color=[255, 0, 0, 200], # RGBA
pickable=True
)
m = Map(layer)
m
# 基于列的PolygonLayer
from lonboard.colormap import apply_continuous_cmap
import matplotlib.pyplot as plt
colors = apply_continuous_cmap(gdf['value'], plt.cm.viridis)
layer = PolygonLayer.from_geopandas(
gdf,
get_fill_color=colors,
get_line_color=[0, 0, 0, 100],
pickable=True
)
Map(layer)
Datawrapper
最适合: 文章和报告的出版就绪的等值线图和比例符号地图。
优点: 开箱即用的精美、专业默认。生成可嵌入、响应的iframe,适用于任何CMS。读者可交互(悬停、点击)而无需运行代码。可访问且移动友好。易于程序化更新数据以更新数据。
缺点: 需要Datawrapper账户(有免费层)。限于Datawrapper支持的分界文件—您无法引入任意几何。自定义可视化灵活性较低。
使用时: 您需要抛光的地图以用于出版。理想用于显示区域统计的等值线图(按州的失业率、按县的COVID病例、按选区的选举结果)。您的受众将在浏览器中查看地图,而非笔记本。
与.explore()或lonboard不同,您不传递原始几何—而是使用标准代码(FIPS、ISO等)将数据与Datawrapper的内置分界文件匹配。
import datawrapper as dw
import pandas as pd
# 读取API密钥
with open('datawrapper_api_key.txt', 'r') as f:
api_key = f.read().strip()
# 准备数据,带有匹配Datawrapper分界文件的位置代码
# 对于美国州:使用2字母缩写或FIPS代码
# 对于国家:使用ISO 3166-1 alpha-2代码
df = pd.DataFrame({
'state': ['AL', 'AK', 'AZ', 'AR', 'CA'], # 州缩写
'unemployment_rate': [4.9, 3.2, 7.1, 4.2, 5.8]
})
# 创建等值线图
chart = dw.ChoroplethMap(
title='按州失业率',
intro='2024年失业劳动力百分比',
data=df,
# 地图配置
basemap='us-states', # 内置美国州分界
basemap_key='state', # 数据中带有位置代码的列
value_column='unemployment_rate',
# 样式
color_palette='YlOrRd', # 颜色方案
legend_title='失业率 %',
# 归属
source_name='劳工统计局',
source_url='https://www.bls.gov/',
byline='您的姓名'
)
# 创建和发布
chart.create(access_token=api_key)
chart.publish()
# 获取文章嵌入代码
iframe = chart.get_iframe_code(responsive=True)
print(f"图表URL: https://datawrapper.dwcdn.net/{chart.chart_id}")
# 用新数据更新(用于实时更新地图)
new_df = pd.DataFrame({...}) # 更新数据
existing_chart = dw.get_chart('YOUR_CHART_ID')
existing_chart.data = new_df
existing_chart.update()
existing_chart.publish()
可用Datawrapper基图包括:
us-states、us-counties、us-congressional-districtsworld、europe、africa、asia- 国家特定地图(例如
germany-states、uk-constituencies)
学习资源
- NICAR(调查记者与编辑)
- 美洲新闻学骑士中心
- 数据新闻学手册(datajournalism.com)
- Flowing Data(flowingdata.com)
- The Pudding(pudding.cool)- 示例
- Sigma Awards(https://www.sigmaawards.org/)- 示例