面向安全的数学指南-全-

面向安全的数学指南(全)

原文:zh.annas-archive.org/md5/2b84dcc94028de28baf9c54b8be04db0

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:注释

第三章

第四章

第五章

第六章

第七章

第八章

第九章

第十章

第十一章

第十二章

第十三章

第二章:索引

请注意,索引链接指向每个术语的大致位置。

页面数字后跟斜体的 ft,分别表示图形和表格。

A

A/B(蓝/绿)测试,263

ACLU(美国公民自由联盟),177

add_edge 函数,33

add_node 函数,33

AEQD(方位等距),152–153

aeqd_to_wgs84 函数,152–153

AGP。参见 艺术画廊问题应用

all_pairs_lowest_common_ancestor 函数,86

all_shortest_paths 函数,118

alpha 参数,120

亚马逊云服务(AWS),261

美国公民自由联盟(ACLU),177

Anaconda

安装,4–8

Linux,4–6

macOS,8,8f

Windows,6–8,6f,7f

安装 Spyder IDE,10

Jupyter Notebooks,11–12

虚拟环境设置,9,63

异常检测,47,53

应用状态规划,235–236

apply 函数,70,168–169,192

apt-get,11

区域度量,190

责任领域(AORs)

艺术画廊问题,219–223,220f,225

紧急服务规划场景,171,173

艺术画廊问题(AGP)应用,209–232

高级特性,233–256

Python 中的图形,245–250

进程并行,241–245

运行示例应用,254–255

保存与重新加载数据,251–254

状态管理器开发,237–241

用户交互映射,234–237

算法与数据结构,216–231

责任领域,219–223,220f

复杂多边形,223–225,224f

视场和有效范围,229–231,230f

贪心着色,218,218f

三角剖分,216–217,217f

加权和预算覆盖,225–229,225f,228f,229f

交付管道,257–267

使用云微服务分发,260–264,261f

使用 PyArmor 授权,264–265

开源交付,265–266

使用 Python 解释器打包,259–260

设置脚本,258–259

现有研究,212–214,213f,214f

几何和图表示,214–216,215f

前提,209–210

用例,211–212

assign_triangles 函数,221

association 函数,195

关联矩阵,194–197,194f

属性字符,22

权威分数

HITS 算法,80–83

社交网络模拟,104,106

更新权威分数,81

AWS(亚马逊云服务),261

axis 参数,71,167

方位等距(AEQD),152–153

方位投影,152

B

background 变量,246

集成法,182

平衡交换,75

Baran,Paul,121

基站(塔)

定义,142

收集位置,149–150

识别,144

流氓,143

中介中心性,35–38,35f,57

偏置随机游走(偏置游走),97,100,104,111

BIM(建筑信息建模)程序,211

Bledsoe,Woodrow Wilson,176,179

盲点,211,232

blit 函数,247,250

蓝绿(A/B)测试,263

布尔值

定义,18

交点,153–154

符号,18–19,21t

分支节点,84–85

Brandes,乌尔里奇,36

广播地址,60

建筑信息建模(BIM)程序,211

C

-c (--count) 选项,63

C 语言,92,216

C++ 语言,183

capacity 属性,112,114

容量约束,121

cascaded_intersections 函数,155,157

单元 ID (CIDs),146

单元,已定义,12

中心性,35–38,51f

中介中心性,35–38,35f,57

已定义,35

度数中心性,37–38

按协议,52–61

识别异常流量水平,54–57

邻居和信息交换比率,57–61

端口号子图,52–54

重心,已定义,136

重心位置,136–137,137f

check_clicked_existing_vertex 函数,240

check_clicked_within_room 函数,240

choice 函数,103–104,116,122,193

色度键拍摄(绿幕拍摄),249

Chvátal,瓦茨拉夫,212

Chvátal AGP 定理,212–213

Chvátal 的上界,212–213

CIDs(单元 ID),146

circle 函数,247

city_gj 变量,167

city_shape 变量,167,170

clear_surface 函数,249

克里克

分析,39–40,39f,61

已定义,76

识别,76–78,78f

最大克里克,40,77

闭合链,129

亲密度,35,38,52

云微服务,分发,260–264

同位,138–140,139f

公共祖先,84–86

完全子图,39

complex 函数,150–151

计算几何理论,127–140。另见 艺术馆问题应用

常见操作,132–140,133f

重心位置,136–137,137f

同位,138–140,139f

周长,137–138

平铺,133–136,134f

定义,127

人脸识别,175–205

位置三角剖分,141–160

形状,128–132

线段,128–129

点,128–129

多边形,129–132,130f,131f

顶点顺序,132

Voronoi 图,161–174

计算机视觉,179,184。另见 人脸识别

concat 函数,70–71

音乐会安全场景,132–140,133f

重心位置,136–137,137f

同位,138–140,139f

周长,137–138

平铺,133–136,134f

conda 工具,4–7,9,11

连通组件,41

连通图,40–41

连通性,40–41,41f

连接(边),图中的 i,27

保守约束,121

约束 Delaunay 三角剖分,214,216

contains 函数,81,138–139

continue 关键字,116,118

controlled 变量,239–240

凸包,187,187f

coords 参数,216

相关性比率,197–198

correlation_ratio 函数,198

create_using 函数,63

交叉验证,188,200

cross_val_score 函数,200

切割集,121–122

cv2 库,184

cv 参数,200

图的周期,32

循环图,32

D

DAGs(有向无环图),85

DataFrame 对象,69–73,76,81,88,107,150,167–170,191–193,195

数据管理服务,261f,262

DataSaver 类,227–228,252

DDoS(分布式拒绝服务)攻击,38

决策树,179–182,180f,201

度中心性,37–38

德劳内三角剖分,216

交付流水线,257–267

使用云微服务分发,260–264,261f

使用 PyArmor 进行授权,264–265

开源交付,265–266

使用 Python 解释器打包,259–260

设置脚本,258–259

描述性安全分析,90

detector 变量,183,186

确定性有限状态机(FSM),95,97

设备追踪应用,148–159,148f

大地测量多边形,150–153

交点,153–157

映射与比较结果,157–159,158f

减小搜索区域,159

塔位置,149–150

字典推导式,15–17,137

difference 函数,144

DiGraph 对象,38,42,42f,52,63,108,115

Dijkstra 算法,77

dim 参数,51

有向无环图(DAGs),85

有向图,31–32

中介中心性,36

完全子图,40,76–77

在 NetworkX 中创建,33

度中心性,37–38

边的多重性,43

HITS 算法,80

网络分析图,63

端口号子图,52

资源分配,108–109

社交网络分析,73

状态机图,94,94f

有向优先附着(DPA),114

不连通图,40–41,41f

离散分类,176

DisplayAGP 类,242–243

Display 类,246–248,247f

distance 函数,138,164,222

距离度量,190

分布式拒绝服务(DDoS)攻击,38

分工,241

.__doc__ 属性,237

Docker,262–263

文档字符串,236–237

DPA(有向优先附着),114

draw 函数,34,247

绘制状态,235

dtypes 属性,72

DummyClassifier 类,200

虚拟分类器,199–202

dump 函数,203

dumps(转储字符串)函数,147,252

E

边属性,32–33,43,50,226

边的容量,112–113

边的多重性,32,42–43,42f

图中的边(连接),in 图,27

多边形中的边(边;面),in 多边形,129

边的权重,32–33,43,52–53,62–63,73

有效范围

蜂窝网络,144

安全传感器,211,213,229–230

电子前沿基金会,177

eliminate_small_areas 函数,155–156

紧急服务规划场景,163–173

城市形状,164–167,165f

距离函数,164

生成器,167–169,169f

Voronoi 划分,170–173,171f,172f

经验均值(样本均值),93

集成分类器,201

伦理

面部识别,178–179

社交网络分析,89

跟踪,144–145

欧几里得距离,164

event 类,237–238

exchange_ratios 函数,60

探索性分析(无监督学习),176

F

面部检测器组件,183

面部(边缘),i 在多边形中,129

面部识别科学工作小组(FISWG),189

面部识别,175–205,178

数据加载,191–193

数据集,177–178

决策树分类器,179–182,180f

定义,175

伦理问题,178–179

面部统计数据,189–190,189f

特征工程,193–198

关联矩阵,194–196,194f

相关比率,197–198

互信息分类,196–197

定位面部特征点,185–188,187f

内存管理,190–191

模型持久性,203–204

模型训练,198–203

建立基线,199–200

随机森林,201–202

数据划分,199

测试持出图像,202–203

概述,176–177

图像数据处理,184–185,185f

概念验证,188–204

表示面部几何,182–184,182f

特征,i 在数据库中,71

视场,211,213,229–231,230f

file_to_graph 函数,62

find_cliques 函数,40

有限状态机(FSMs),93–95,94f,100–101

Fisk,Steve,212

FISWG(面部识别科学工作组),189

拟合分类器,200,203

flip 函数,247

Floor 类,234

流程函数,109

font_color 函数,34

format 参数,165

冻结应用程序,259–260

from_dict 函数,253–254

FSMs(有限状态机),93–95,94f,100–101

functools 库,150

G

GCP(Google Cloud Platform),261

通用数据保护条例(GDPR),178

一般游戏玩法(GGP),98

Generator 对象,40

生成器(种子)

已定义,162

收集,167–169,169f

Voronoi 镶嵌,162–163

地理编码,145

GeoDataFrame 类,150

GeoDataFrame 对象,168–169

GeoJSON,150,165–167

地理定位,145,149–150,159,167

GeoPandas 库,150,169–170

geovoronoi 库,163,170

get_front_face_detector 函数,183

get_image_files 函数,191

get_mods 函数,239

get_shapely_circle 函数,153

GGP(一般游戏玩法),98

Gini 不纯度系数,181

GIS Stack Exchange,151

GitHub,xxiv,10,48,258,265

目标导向规划,98

Google Cloud Platform (GCP),261

图构建服务,261f,262

图形元素,245–250

Display 类,246–248,247f

层,248–250

Sprite 类,248–250,250f

Surface 类,246–248,247f

图,定义,27

图论,27–44。另见 艺术画廊问题应用

在 NetworkX 中创建图,32–34,34f

图的属性,34–43

中心性,35–38

克里克,39–40

紧密度,35

连接性,40–41

边的多重性,42–43

图,定义,27

概述,31–32

用途,28–31,28f,30f,31f

Graphviz,33

greedy_color 函数,218

贪心着色,212–214,213f,218,218f,245

希腊字母和函数,22,22t

绿幕拍摄(色键拍摄),249

古根海姆博物馆,214,214f另见 艺术画廊问题应用

H

-h (--help) 选项,63,254

handle_click 函数,240–241

handle_keydown 函数,238–239,241

handle_keyup 函数,239–241

硬分类,203

硬件并行性,263

has_path 函数,110

海,安德鲁,29

高阶函数,150

定向梯度直方图(HOG),183

HITS(超链接诱导主题搜索),80–83,104,109

hits 函数,81

HNI(家庭网络身份),146

留出集

测试留出图像,202–203

真值,192–193

hole_p 变量,224

漏洞。参见 线性环多边形

家庭网络身份 (HNIs),146

水平扩展,262–263

集线器

已定义,80

HITS 算法,80–83

社交网络仿真,104,106

更新集线器分数,82

hub_send 函数,106

超链接诱导主题搜索 (HITS; 集线器与权威),80–83,104,109

假设检验,200

I

-i (--iface) 选项,63

-i all 选项,64

ICMP (互联网控制消息协议) 数据包,49–50

IER (信息交换比率),59–61

IFD (信息流距离),100–104

imutils 库,184,236

入度中心性,37–38,54–56,59–60,78,85,113,115,121

in_degree 函数,55,79

in_edges 函数,59,114

妥协指示符 (IoCs),47

信息熵,74

信息交换比率 (IER),59–61

信息流距离 (IFD),100–104

信息流游戏,110–124

边容量,112–113

游戏阶段,113–117

消息传递阶段,115–117

网络中断阶段,117

网络演化阶段,113–115

游戏仿真,118–120

对玩家 2 的改进,120–124,124f

源节点和汇节点选择,117–118

加权随机选择,111–112

信息传播,74–76

知情同意

人脸识别,178

跟踪,145

__init__ 函数,249

init 函数,246

init_surface 函数,250,253

输入字母表, 94–96, 101–102, 113

决策树中的实例, i n, 180

Internet 控制消息协议(ICMP)数据包, 49–50

解释器

已定义, xxiii

打包, 259–260

intersection 函数, 144, 154

寻找交点, 153–157

intersects 函数, 138

IoCs(入侵指示符), 47

isinstance 函数, 157

items 函数, 186

J

joblib 库, 203

join 函数, 244

json 库, 251

json 参数, 168

Jupyter Notebooks, 11–12

K

key_features 变量, 196

key 参数, 53, 222, 239–240

密钥空间, 111–112

动力学信息, 75–76, 78–79

克利,维克托,212

Kubernetes, 262–263, 266

L

-l--load)参数, 63

labels 函数, 34

LAC(位置区域代码), 146

lambda 函数, 53

地标检测器组件, 183, 188, 191

图形中的图层, i n, 248–250

LCA(最低公共祖先), 84–87

叶节点, 85, 180–181, 201

留一法(LOO)算法, 188–189, 199–200

left_click 函数, 241

len 属性, 50

libpcap 库, 47

授权许可, 使用 PyArmor, 264–265

关键员工, 109–110, 112

线性环多边形(环;孔)

艺术画廊问题, 223–225, 224f

概述, 130–131, 131f

线段

AGP 算法, 218

创建, 129

概述, 128–129

周长, 138

多边形, 129–132

Voronoi 分割, 162–164

LineString 类, 129

LineString 对象, 129, 190

链接预测理论, 100

Linux

安装 Anaconda, 4–6

不使用 Anaconda 安装 IDE, 11

Jupyter 笔记本, 47

网络卡的混杂模式, 63

开源交付, 266

数据包捕获库, 11

列表推导

字典推导与, 16–17

紧急服务规划场景, 167, 170

人脸识别, 193, 197

查找交点, 157

识别团体, 77

识别最具吸收性的节点, 59

识别异常流量水平, 54

局限性, 15

概述, 14–15

端口号子图, 52

load 函数, 203–204, 227, 246

loads 函数, 70, 165, 253

locate_landmarks 函数, 186, 188, 191

位置区域代码(LACs), 146

locations 变量, 169

位置三角测量, 141–160

设备跟踪应用, 148–159, 148f

大地测量多边形, 150–153

交点, 153–157

映射和比较结果, 157–159, 158f

缩小搜索范围, 159

塔位位置, 149–150

伦理, 144–145

网络接口数据, 142–144

OpenCellID API 结构, 145–148, 147f

概念验证, 148–159

loc 函数, 73

逻辑语句, 18–20

LOO(留一法)算法, 188–189, 199–200

lookup_tower 函数, 146, 148–149

松耦合, 253–254

最低公共祖先(LCA),84–87

LucidChart,234

M

MAC(媒体访问控制)地址,48–50

机器学习(ML),18,176–204

macOS

安装 Anaconda,8,8f

网络卡的混杂模式,63

数据包捕获库,47

Maltego,29–30

Markdown,12

Mastodon 数据分析,67–90

将数据转换为图,69–73

构建图,72–73,74f

数据检查,69

数据结构化,69–72

定义,67

概念验证,87–88

研究问题,74–87

团和最有影响力的用户,76–78,78f

信息传播,74–76

最受影响的用户,78–79,79f

节点祖先,83–87,84f,86f

基于主题的信息交换,79–83,82f

数学约定。编程和数学约定

数学符号,18–22

属性字符,22

布尔符号,18–20,19t

希腊字母和函数,22,22t

重载符号,18f

集合符号,20–22,21t

Matplotlib 库,32–34

最大流最小割定理,112,121–122

最大团,40,77

最大面积阈值,219

max_iter 参数,81

MCCs(移动国家代码),146

媒体访问控制(MAC)地址,48–50

会员规则,20,21t

网格,218,220,220f,225–229,225f,229f

度量空间,163–164,167

MI (互信息) 分类, 196–197

微服务, 261–264, 261f

斯坦利·米尔格拉姆, 68

minimum_cut 函数, 122

最小可行产品 (MVP), 210, 257

min_samples_leaf 参数, 201

min_samples_split 参数, 201

机器学习 (ML), 18, 176–204

移动国家代码 (MCCs), 146

移动网络代码 (MNCs), 146

模型持久化, 203–204

模型训练, 198–203

建立基准, 199–200

随机森林, 201–202

拆分数据, 199

测试保留图像, 202–203

货币化, 258–259, 263–266

蒙特卡罗模拟, 91–125

信息流游戏, 110–124

概述, 92–93

概念验证, 109–124

随机游走与, 95–97, 96f, 99f

模拟, 定义, 92

社交网络模拟, 100–109

最受影响的用户, i 识别, 78–79, 79f

最具影响力的用户, i 识别, 76–78, 78f

mp_agp_floorplan 函数, 245

mp_agp_solver 函数, 244

mp_solve_floors 函数, 244

多类别分类, 179

MultiDiGraph 对象, 38, 42, 42f, 49–50, 53, 58, 62

多进程 (处理器并行), 243–245

互信息 (MI) 分类, 196–197

MVP (最小可行产品), 210, 257

N

names 参数, 167

邻居, 31, 57–61, 58f

嵌套对象, 70, 251

n_estimators 参数, 201

NetGear Ocuity 摄像头,229

net_graph 对象,50,58,60

网络分析图,45–65

建筑,47–51,51f

中心性,52–61

检查邻居,57–61,58f

识别异常流量级别,54–57,56f

端口号子图,52–54

为…识别数据,48–49

网络拓扑,46,46f

数据包分析,46–51,51f

概念验证,61–64

网络接口卡(NICs),48

NetworkX 库

艺术画廊问题应用,236

中介中心性,36

按协议的中心性,52–53,58–59

创建图,32–34,34f

度中心性,38

贪心着色,212,214–215

网络分析图,62–63

数据包分析,50

社交网络分析,70,75,80–81,86–88

社交网络演化,93,95,109,118

网络接口卡(NICs),48

节点祖先,83–87,84f,86f

节点属性,32

节点。参见 顶点

Nominatim,164–165

non_edges 函数,118

非简单图(伪图),32

normalized 参数,36

Npcap 库,47

number_of_cliques 函数,40

NumPy 库,xxiii,55–56,80,103,170,186

O

混淆,264–265

Obstacle 类,235

Obstacle 对象,251,253

obstacles 属性,253

only_poly1 变量,155

OpenCellID,141–160

API 结构, 145–148, 147f

设备跟踪应用, 148–159, 148f

大地测量多边形, 150–153

交点, 153–157

映射和比较结果, 157–159, 158f

减少搜索区域, 159

塔位置, 149–150

网络接口数据, 142–144

概念验证, 148–159

开源交付, 265–266

开源情报(OSINT), 29–30, 30f

OpenStreetMap, 163–164, 168

optparse 库, 62–63

osm 函数, 168

外度中心性, 37–38, 51–54, 57–59, 77, 85, 101–103, 113, 121

out_degree 函数, 53

out_edges 函数, 59

异常值, 55–56, 60, 181

overlaps 函数, 138

重载符号, 18, 18f

P

打包, 259–260

数据包分析, 46–51, 51f

数据包捕获(pcap)文件, 47–48, 50, 61–63

packet 对象, 50

数据包,已定义, 121

packets 变量, 50

Pålsson, Mikael, 213

pandas 库, 5, 69–72, 81, 88, 150, 167–169, 191–192

并行开发, 263–264

并行性

硬件并行性, 263

进程并行性, 241–245

处理器并行性, 243–245

线程并行性, 241–243

partial 函数, 151–152

部分函数, 151

partition 函数,155

路径长度

最低公共祖先,85

返回平均值列表,117–119

自环,32

小世界现象,68

路径,已定义,31

PBX(私人分支交换机),54

pcap(数据包捕获)文件,47–48,50,61–63

pcap_graph 函数,62

周长,137–138

个人身份信息,178

Phil 的游戏工具库(PGU),250

物理渗透测试,212

pickle 对象,203–204

pickle 库,203–204,251

皮内拉斯县警长办公室,177

pip 工具,10–12,258

平面直线图,216

player_one_turn 函数,115–116

png 库,236

点,已定义,128–129

Polygon 类,129

polygon_geojson 参数,165

多边形,129–132,130f, 131f

复数,130,132,134–135,138,214,223–225,224f

凹的,130,130f

Point 对象转换为地理多边形,150–153

凸的,130,130f, 132,136,187

不规则的,130,133–136

线性环,130–131,131f,223–225,224f

正交的,213–214

常规的,130

简单的,130–131,134f,210

poly_shapes 变量,170,172

多树,85

总体均值,120,123–124

端口号子图,52–54

post_df 对象, 71–73, 76, 107

潜在信息, 75–76, 78

predict 函数, 200, 202

预测分析, 91

predict_proba 函数, 203

优先附着, 68, 113–114

预防性安全分析, 90

私有分支交换机(PBX), 54

process_jpg 函数, 184, 186

处理器并行性(多进程), 243–245

进程并行性, 241–245

处理器并行性, 243–245

线程并行性, 241–243

编程与数学约定, 13–23

数学符号, 18–22

属性字符, 22

布尔符号, 18–20, 19t

希腊字母和函数, 22, 22t

重载符号, 18, 18f

集合符号, 20–22, 21t

语法构造, 13–18

字典推导式, 15–17

列表推导式, 14–15

压缩与解压, 17–18

Proj 类, 151–152

项目经理, 264

概念验证, ix

面部识别, 188–204

位置三角测量, 148–159

最小可行产品对比, 210

网络分析图, 61–64

社交网络分析, 87–88

社交网络演化, 109–124

Voronoi 图, 163–173

protocol_subgraph 函数, 52–55

协议子图, 54–56, 56f

代理网络, 35–36, 35f

伪图(非简单图), 32

纯度, 181

PyArmor, 264–265

PyGame 库, 236–240, 246–250

事件, 237–239

图形元素, 246–247, 247f

PyInstaller, 259–260

PyPi, 258–259, 266

Pyplot 库, 32–33

pyproj 库, 150–152

勾股定理, 18, 18f, 164

Python

环境设置, 3–12

硬件要求, 3

安装 Anaconda, 4

Jupyter Notebooks, 11–12

Spyder IDE, 10–11

虚拟环境设置, 9

virtualenv 包管理器, 10–11

解释器,打包与, 259–260

使用的原因, xxii–xxiii

缺点, xxiii

Q

Queue 类, 244

R

-r (--raw-out) 参数, 63

RA(资源分配), 108–109

randint 函数, 201

RandomForestClassifier 类, 201

RandomForestClassifier 对象, 202–203

随机森林, 179, 182, 201–203

random_layout 函数, 51

随机游走, 95–104, 117–119, 125

有偏, 97, 100, 104, 111

蒙特卡罗模拟和, 97–99, 99f

社交网络模拟, 100–104

状态机和, 96–97, 96f, 99f

均匀地, 96–97, 96f, 101–104, 117

range 函数, 16–17, 22

RangeIndex 属性, 71

比率

相关比率, 197–198

面部识别, 190

信息交换比率, 59–61

rdpcap 函数, 50, 62

read_csv 函数, 167, 192

read_weighted_edgelist 函数, 63

递归函数, 31

Red Hat, 266

回归问题, 176

重复采样算法, 98

representative_point 函数, 223

残差信息 (RI) 分数, 75–76

resize 函数, 184

资源分配 (RA), 108–109

资源规划问题。参见 艺术画廊问题应用;紧急服务规划场景

return_results 参数, 195

reverse 选项, 53

reverse 参数, 59, 137

RI (残差信息) 分数, 75–76

right_click 函数, 241

环。参见 线性环多边形

Room 类, 249, 251, 253–254

根节点, 84–85, 180–181

row_to_str 函数, 167

r_posts 对象, 76

S

-s (--graph-out) 选项, 63

样本均值(经验均值), 93

样本大小确定, 98

保存的状态, 235

save_file 函数, 227

save_graph 函数, 62

save_packet 函数, 62

save_project 函数, 227–228

保存和重新加载数据, 251–254

从 JSON 文件加载, 252–254

保存到字典, 251–252

扫描码, 239–240

Scapy 库, 47, 50, 62–63

scikit-learn, xxiii, 196–197, 199–201, 203

SciPy 库, 55, 80, 170

scored_neighbor_select 函数, 106

scores 参数, 111

screen 属性, 249–250

seed 参数, 33–34

种子。参见 生成器

select_dtypes 函数, 192

自循环, 31–32

Series 对象, 71, 191, 198

setattr 函数, 253

set_colorkey 函数,249

set_file 方法,242

集合生成符号(SGN),22

set_mode 函数,246

集合符号

概述,20–22,21t

保留集,21,21t

集合生成符号,22

set_region_areas 函数,227

设置脚本,258–259

setuptools 库,258

7Zip,252

SGN(集合生成符号),22

Shapely 库

艺术画廊问题应用,216,219,222–223,235

计算几何理论,128–129,131–132,134,138

紧急服务规划场景,164,170

面部识别,183,188,190

位置三角化,144,148–150,152,154

shape_to_np 函数,186

外壳布局,58,58f

Shewchuk, Jonathan,216

shifted 变量,239,241

鞋带算法,136

shortest_path_scores 函数,117–119

多边形的边(edges),i 中,129

签名检测,47

simple 函数,151

简单图,32,85

单点故障,109–110

汇聚节点,110,113,117–119,121

六度分隔,68

small_area 参数,157

小世界实验,68

Snort,48

Snow, John,163

社交网络分析(SNA),67–90

关于的警告,89

将数据转换为图,69–73

构建图,72–73,74f

检查数据,69

数据结构化,69–72

已定义,67

概念验证,87–88

研究问题,74–87

社团和最有影响力的用户,76–78,78f

信息传播,74–76

最受影响的用户,78–79,79f

节点祖先,83–87,84f,86f

基于主题的信息交换,79–83,82f

小世界现象,68

社交网络演化,91–125

有限状态机,93–95,94f

信息流游戏,110–124

边容量,112–113

游戏阶段,113–117

游戏模拟,118–120

玩家 2 的改进,120–124

源节点和汇节点选择,117–118

加权随机选择,111–112

蒙特卡洛模拟,92–93,97–100,99f

概念验证,109–124

随机游走,95–97,96f,99f

模拟,已定义,92

社交网络图,29,39–40,39f

社交网络模拟,100–109

信息流距离,100–104

资源分配,108–109

基于主题的影响,104–108

软分类器,203

sorted 函数,53,137,198

源节点,36,73,78,110,113,117–119,121

意大利面模型(风暴路径图),93

稀疏邻接矩阵,80

spring_layout 函数,33,82–83

Sprite 类,248–250,250f

精灵,248–250,250f

Spyder IDE,4,10–11

Stack Overflow,153

Ståhl,Joachim,213

斯坦福大学,98,125

启动状态,234–235

起始状态,234–235

状态机

有限状态机,93–95,94f,100–101

状态机图,30,31f

状态管理器

应用状态规划,235

发展过程,237–241

事件驱动的特性,237

目的,235,237

Steiner 点,219,228–229

随机有限状态机,95,97

风暴路径图(意大利面模型),93

strip 函数,70

子状态,238

sum 函数,59

超级碗 XXXV,176–177

监督学习,176,179

Surface 类,246–248,247f

surface_size 属性,249

扫描线算法,154–155

语法结构,13–18

字典推导,15–17

列表推导,14–15

压缩和解压,17–18

T

target 参数,244

TCP 握手图,42,42f

TCP 数据包,49–50,61

终态,96,99–100

term_subgraph 函数,106–107

镶嵌,133–136,134f

艺术画廊问题,214,216–219,223–224,226–228,245,252

面部识别,189–190

Voronoi 镶嵌,162–173

theil_u 参数,195

Theil 的 U,195

Thread 类,242

线程并行,241–243

三色问题,212–213

瓦片,133

timeline 函数,88

to_dict 函数,251,253–254

tol 参数,81

基于主题的影响,104–108

基于主题的信息交换,79–83,82f

地形,33,113

拓扑排序,85

touches 函数,138

塔(基站)。参见 基站

transform 函数,151–153

过渡,31f,94–96,99–101

旅行图,28–29,29f,32

Triangle 库,216,219,223–229,236,245,252,262

Triangle Solver 服务,261f,262

triangulate 函数,134,136,216–217,219,221,223–224,226–227

真值表,19

ttest_ind 函数,123

推特,80

双样本 t 检验,123,124f

type 函数,203

U

UDP 数据包,49–50

UML(统一建模语言),234

不平衡交换,75

无向图,31–32

介数中心性,36

击毙,39–40,39f,76–77

连通组件,41

在 NetworkX 中创建,33,34f

度中心性,37

无向优先附加(UPA),114

unicode 属性,239

统一建模语言(UML),234

uniform 参数,200

均匀随机游走,96–97,96f,101–104,117

卸载函数,257–259

unique 函数,103

埃塞克斯大学,177

华盛顿大学,212

无监督学习(探索性分析),176

无权图, 32, 43, 214

Unwired Labs, 142, 147

UPA(无向优先附加), 114

urlencode 函数, 165

用户交互映射, 234–237, 234f

应用状态规划, 235–236

文档, 236–237

用户界面服务, 261f, 262–263

user_to_series 函数, 70

V

顶点着色问题, 212

vertex_list 属性, 249

顶点(节点)

定义, 129

图论, 27

顶点顺序, 132

VirtualBox, 260

虚拟环境设置, 9, 63

virtualenv 包管理器, 10–11

网络电话(VoIP), 54, 57

Voronoi 图, 161–174

紧急服务规划场景, 163–173

城市形状, 164–167, 165f

距离函数, 164

生成器, 167–169, 169f

平铺, 170–173, 171f, 172f

局限性, 173–174

概念验证, 163–173

平铺, 162–163, 162f

voronoi_regions_from_coords 函数, 170

Voronoi 平铺, 162–163, 162f, 170–173, 171f, 172f

W

weighted_choice 函数, 106, 111–112, 114, 118

加权图, 32–33, 52–53, 55, 62–63, 77

加权随机选择, 95, 97, 106, 111–115

weight 参数, 53, 77

WGS(世界大地测量系统), 152

wgs84_to_aeqd 函数, 152

where 函数, 55, 60

WiGLE, 144, 159

Windows

冻结传输, 260

安装 Anaconda, 6–8, 6f, 7f

Jupyter Notebooks, 11–12

网卡在混杂模式下, 63–64

数据包捕获库, 47

设置 virtualenv, 10

Spyder IDE, 11

临时目录, 252

WinPcap 库, 47

WinPython, 11

WireShark, 46–47

世界大地测量系统(WGS), 152

写一次,读多次(WORM)工作流, 62

write_weighted_edgelist 函数, 62–63

wrpcap 函数, 62

wrs_connect 函数, 114, 116

wrs_disconnect 函数, 115–116

X

X_test 变量, 200

X_train 变量, 200

Y

y_test 变量, 200

y_train 变量, 200

Z

Zenmap, 46, 46f

零和博弈, 98

ZipFile 类, 252

zip 函数, 17, 197, 202

zipf 变量, 252

压缩与解压, 17–18

zscore 函数, 55–56

Zychlinski, Shaked, 195

第三章:设置环境

让我们从设置编程环境开始,后续我们将在本书中一直使用这个环境。Python 足够灵活,可以在多种平台上运行,因此我无法涵盖所有可能的安装和配置选项。话虽如此,由于我们将分析的一些问题可能在计算上比较昂贵,我假设你是在笔记本或台式电脑上进行实验,而不是在平板或手机上。一颗多核 CPU 将帮助加速一些处理过程。虽然这不是必须的(而且我也不会使用它们),一些库也可以利用现代 GPU,因此我鼓励你尝试使用它们。最后,一些操作可能会消耗大量内存。我建议至少有 4GB 的 RAM 可用,但最好是 8GB 或更多。对于每个环境,你必须平衡实现成本与生成解决方案所需的时间。在第十三章,我们将讨论如何将问题分布到多个小平台上进行处理。

我将介绍两种设置:一种是简单设置,另一种是高级设置。如果你是 Python 编程新手,我建议你从简单设置开始,它使用 Anaconda 进行包和环境管理,并安装一个名为 Spyder 的集成开发环境(IDE)。Spyder IDE 专门针对数学和科学应用进行了优化,是即将到来的项目的绝佳选择。

如果你熟悉环境和包管理的细节,并且已经配置了 Python 环境,那么高级设置将介绍如何使用虚拟环境将你的实验工作区与其他生产工具隔离,以及如何手动安装所需的包。

使用 Anaconda 进行简单环境配置

我们将从安装 Anaconda 开始,Anaconda 是一个平台,旨在轻松管理多个 Python 环境,即使是没有系统管理背景的人也能使用。Anaconda 将使安装我们所需的包变得简单,随着时间的推移进行更新,并确保环境依赖关系保持一致。Anaconda 专门为数据科学和机器学习工作流设计。Linux、Windows 和 macOS 都有可用的安装程序。请前往 Anaconda 的分发页面(www.anaconda.com/distribution),并下载适用于你平台的最新安装程序版本。

现在让我们一起看看 Linux、Windows 和 macOS 的安装说明。

Linux

从 Anaconda 链接下载的文件实际上是一个帮助下载和配置必要包的 shell 脚本。你应该以管理员身份运行此脚本(例如在 Debian 上使用 su)。首先,在安装脚本所在的目录中打开终端。你可以使用以下命令来执行安装程序:

$ **chmod +x** `Anaconda3-202X.0X-Linux-x86_64.sh`**;**
$ `./Anaconda3-202X.0X-Linux-x86_64.sh`**;**

要开始安装,你需要使用 chmod +x 标记此脚本为可执行文件。确保更改脚本的名称,使其与下载的版本匹配。然后,你可以使用默认的 shell 解释器运行安装程序。在设置过程中,你需要确认一些安装选项。在大多数情况下,默认选项已经足够好。如果你计划更改任何默认设置,请花时间阅读文档——某些选项可能会带来意想不到的后果。安装完成后,你可以使用新安装的 conda 工具验证一切是否正常。打开一个新的终端并执行以下命令:

$ **conda info** 

你应该会看到类似以下的输出:

 active environment : base
    active env location : /home/dreilly/anaconda3
            shell level : 1
       user config file : /home/dreilly/.condarc
 populated config files : 
          conda version : 23.`X`
    conda-build version : not installed
         python version : 3.`X`
       virtual packages : __glibc=2.23
       base environment : /home/dreilly/anaconda3  (writable)
           channel URLs : https://repo.anaconda.com/pkgs/main/linux-64
                          https://repo.anaconda.com/pkgs/main/noarch
          package cache : /home/dreilly/anaconda3/pkgs
                          /home/dreilly/.conda/pkgs
       envs directories : /home/dreilly/anaconda3/envs
                          /home/dreilly/.conda/envs
               platform : linux-64
             user-agent : `<platform-user-agent-string>`
                UID:GID : 1000:1000
             netrc file : None
           offline mode : False

这里有一些有用的信息。首先是 user config file,即用户配置文件的位置。编辑这个文件可以让你个性化 Anaconda;如果你计划在 Anaconda 中做大量工作,值得了解一下。接下来是 conda versionpython version 两项。将 conda version 与最新的 Anaconda 版本进行比较,确保你拥有最新的工具。python version 是 Anaconda 在创建环境时会使用的默认 Python 解释器。你可以为每个环境设置特定的 Python 版本,但确保默认设置为你首选的版本,可以节省一些时间,尤其是当你忘记在创建环境时指定版本时。

channel URLs 字段告诉你当 conda 尝试安装新包时,远程位置的检查点。修改这个列表时要小心。如果添加了不可信的源仓库,攻击者可能会用恶意版本替换一个合法的包,比如 pandas,这会带来安全风险。定期检查这个字段,以确保它不包含任何未识别的渠道,也是一个好主意。package cache 字段显示 Anaconda 会把已安装库的包信息存储在哪里。由于多个环境可能会请求相同版本的包,Anaconda 会构建一个已知包的缓存,以加快未来的安装速度,并减少类似环境的创建时间。最后要注意的是 envs directories 字段,它告诉你 Anaconda 会在系统的哪个位置存储与定义每个环境相关的文件,包括安装的包版本的副本。如果你需要排查特定环境中的包冲突,知道这些信息的位置会很有用(尽管 conda 也有帮助处理的工具)。

此时,你的基础环境已经设置完成,可以开始配置你的研究环境了。你可以跳到本章后面 “设置虚拟环境” 部分。

Windows

当你为 Windows 机器运行 Anaconda 安装脚本时,你将看到一个典型的 Windows 风格的安装提示,类似于 图 1-1。

图 1-1:Windows 上的 Anaconda 安装程序

选择你希望基本应用程序所在的目录。如果你的系统有一个大容量的二级硬盘和一个较小的主固态硬盘(SSD),确保将 Anaconda 安装在更大的硬盘上。随着多个环境和解释器以及软件包版本的增加,它可能会随着时间的推移变得相当庞大。

安装程序的其余部分会引导你配置 Anaconda。对于大多数情况,默认设置通常是可以的。如果你计划更改任何默认设置,请花时间阅读文档——某些选项可能会带来意想不到的后果。你可能需要授权安装程序进行更改(通过用户帐户控制弹出窗口)。在某些情况下,当图形界面尝试启动时,你可能会收到错误信息。你通常可以通过明确告诉 conda 去哪里找到正确的可执行文件来修复这个问题。打开运行提示框(在大多数键盘上,你可以使用快捷键 win-R),输入 cmd.exe,然后按回车。进入你安装 Anaconda 的目录下的 scripts 子目录。例如,如果你使用了图 1-1 中的安装目录,那么 scripts 目录的路径将是 C:\Users\IEUser\anaconda3\scripts。然后像这样进入该文件夹:

$ **cd** `C:\Users\IEUser\anaconda3\scripts`

然后运行以下命令:

$ **activate root**
$ **conda update -n root conda**

第一个命令告诉 Anaconda 激活根环境,该环境是在安装过程中创建的,包含默认的 Python 版本和一些基本的软件包。第二个命令告诉 Anaconda 更新根环境中的 conda 应用程序版本。你将被要求按 Y 来确认更新,之后 Anaconda 会安装最新版本的 conda。最后,你可以使用更新后的 conda 版本来更新根环境中的所有软件包,如下所示:

$ **conda update --all**

再次,你将被要求通过按 Y 来确认更新。在设置和更新完成后,你可以访问类似于图 1-2 中的 Anaconda Navigator 界面。

图 1-2:Anaconda Navigator 界面

Navigator 界面可用于软件包管理、虚拟环境管理等。它是管理你的 Anaconda 安装的指挥中心。尽管 Windows 安装确实可以访问 conda 工具(毕竟图形界面背后依赖的就是它),但大多数用户无需直接使用它,因为所有最有用的工具都已被封装在一个友好的界面中。Windows 安装的一个优点是创建虚拟环境的简便性。只需点击 Environments 标签,然后点击 Create。为你的新环境输入一个名称,并选择适当的解释器。就这样!现在你可以跳到“设置虚拟环境”部分。

macOS

当你运行从 Anaconda 链接下载的 .pkg 文件时,你应该会看到类似于图 1-3 的界面。

图 1-3:Anaconda 安装界面

安装程序的其余部分将引导你完成 Anaconda 的配置。默认设置通常适用于大多数情况。如果你计划更改任何默认设置,请花时间阅读文档——有些选项可能会带来意想不到的后果。

请注意,推荐的 IDE(Spyder,稍后介绍)在 macOS 上并不原生支持。不过,你可以通过 port 包管理器安装。如果你使用的是 macOS 系统,可以选择跳过本章描述的 Spyder 设置,选择一个专为你的系统设计的 Python IDE。查看或运行代码示例并不需要 Spyder 的高级功能,因此任何现代 IDE 都足够用了。

设置虚拟环境

任何在 Python 中工作足够长时间的人都见识过在一个空间内处理多个项目时不可避免的混乱。不同的包会要求相同依赖项的不同版本。一个项目需要与另一个项目不同的解释器。这可能会变得非常混乱,而且通常直到为时已晚,你才会意识到自己做出了糟糕的选择,而你需要花上整个周末来解决这个问题。

为了避免所有的头痛和浪费时间,你可以将你的项目分解到独立的虚拟环境中。你可以将虚拟环境看作是一个独立的 Python 世界。一个虚拟环境中的包对其他虚拟环境中的包完全不知情。解释器会自动切换到适合该环境的版本。生活重新变得和谐。在你偏好的终端中,输入以下命令,让 conda 为你的项目创建一个新的环境。

$ **conda create -n** `env_name`**python=3.**`X` **anaconda**

这里的 env_name 是你想为环境命名的名称;根据需要修改它。你可以使用 python=``version 语法指定 Python 版本。示例中会将 Python 3.X 解释器配置为新环境的默认解释器。按 Y 继续。Anaconda 会将 Python 解释器和所有相关库复制到 environments 子目录中。该目录的确切路径将取决于你的操作系统和 Anaconda 的安装位置。在我的系统中,路径为 /home/dreilly/anaconda3/envs/researchenv/

现在花点时间定位这个目录,并记下文件夹的完整路径。稍后你将需要这个路径来正确配置 IDE。要激活你新的虚拟环境并开始在其中工作,在终端中输入以下命令:

$ **conda activate** `env_name`

当你激活 Anaconda 虚拟环境时,管理应用程序会修改操作系统的底层环境变量,以适应项目之间的隔离。PATHPYTHONPATH 和其他变量会被更新,指向你创建的特定隔离 Python 设置。你的终端提示符应该会改变,告诉你当前正在使用哪个虚拟环境。你也可以通过检查 conda info 命令来验证设置。

$ **conda info -e**

结果应该是列出你当前定义的所有虚拟环境,并在当前活动的环境旁边标注一个星号。

通过 Anaconda 安装 IDE

Spyder (www.spyder-ide.org) 是一个用 Python 编写的科学和数学编程环境,专为 Python 设计。它由一群专注的程序员、科学家、工程师和数据分析师设计,旨在满足他们工作的需求。Spyder IDE 最棒的一点是,它可以通过 Anaconda 安装。你可以通过终端运行以下命令来安装:

$ **conda install -c anaconda spyder**

安装完成后,你可以通过命令 spyder 从控制台启动 IDE。

现在你已经设置好了虚拟环境和编码环境,你几乎准备好了!你可以给自己加两分,然后跳到本章的 “Jupyter Notebooks” 部分。

高级设置

使用以下设置,你可以通过虚拟环境将实验工作与生产工作空间隔离。它假设你已经正确安装了 Python 3,并且熟悉使用 pip 工具安装包。为了方便,你可以在本书的 GitHub 仓库中找到一个 setup.py 脚本。

设置 virtualenv

virtualenv 是一个 Python 包管理器,用于隔离不同项目的依赖。使用 virtualenv 可以避免全局安装 Python 包,这样可能会破坏系统工具或导致其他项目中依赖库的不一致。你将通过 pip 工具安装 virtualenv。请输入以下命令:

$ **python -m pip install --user virtualenv**

根据你的安装方式,在 Windows 机器上,你可能需要将命令更改为以下内容:

$ **py -m pip install --user virtualenv**

安装完 virtualenv 模块后,你可以创建虚拟环境。这个过程的细节这些年有所变化,但核心思想保持不变。从 Python 3.9 开始,这种方法是创建虚拟环境的首选方法:

$ **python -m venv** `path_to_new_environment`

不通过 Anaconda 安装 IDE

你可以根据操作平台以多种方式安装 Spyder IDE。Debian Linux 用户可能更倾向于通过 apt-get 安装,而不是通过 Anaconda 安装。Spyder 的官方 Debian 包可以在 Debian 包仓库中找到:

$ **sudo apt-get install spyder3**

这将把 spyder3 应用安装到 /usr/bin/anaconda3。你可以通过以下命令验证该位置:

$ **which spyder3**

虽然可以通过 pip 安装 Spyder,但不推荐这样做(安装过程可能比较复杂)。有关更多信息,请参考 Spyder 安装指南 (docs.spyder-ide.org/installation.html)。

如果你使用的是 Windows,可以通过 Anaconda Navigator 获取 Spyder IDE,并附带一套其他实用的库。在“首页”标签下,你会看到一些与 Anaconda 集成的应用程序示例。点击安装按钮来开始安装过程。安装完成后,“安装”按钮会被“启动”按钮替代,点击后即可启动 IDE。

WinPython (winpython.github.io) 是另一个科学 Python 发行版,类似于 Anaconda,它包含了最常用的科学软件包和工具库。不过有一个显著的区别是,它缺少像 Anaconda 的 conda 工具那样的包管理工具。正是因为缺少这种对初学者友好的工具,最终让我决定在本书中使用 Anaconda。如果你已经在使用 WinPython,你仍然可以跟随本书中的项目,但如果你是从头开始搭建新环境,我强烈建议你选择 Anaconda。

Jupyter Notebooks

恭喜——此时你的开发环境已经完成!不过,还有一个你可以选择安装的工具,它将帮助你更好地使用本书,那就是 Jupyter notebook 服务器。本书中的多个项目配有交互式的 Jupyter notebooks,包含有关数学公式的更多细节、关于图形创建的讨论以及一些可以加速你自己应用开发的模板代码。如果你按照 Debian Linux 上的 Anaconda 安装方式进行操作,那么好消息是:你已经安装了 Jupyter notebook 服务器。如果你是在 Windows 上安装的 Anaconda,可以通过 Anaconda Navigator 获取该应用程序。进入首页标签,点击 Jupyter Notebook 应用下的安装按钮。当“安装”按钮变为“启动”按钮时,你就准备好了。

你也可以通过以下 pip 命令手动在虚拟环境中安装 Jupyter:

$ **pip install --upgrade pip**
$ **pip install jupyter**

然后,你可以使用以下命令从终端启动一个 notebook 服务器:

$ **jupyter notebook**

或者在 Windows 上,你也可以像之前提到的那样,直接通过 Anaconda Navigator GUI 启动 notebook 服务器。无论哪种方式,Jupyter 都会打开一个 web 浏览器,显示你运行命令所在目录的内容。你可以使用网页界面创建新的 notebooks 或打开现有的 notebooks。

如果你对交互式笔记本的概念不太了解,我鼓励你抓住这个机会,了解它们的全部内容。Jupyter 是一个 Web 应用程序,允许你创建和共享包含实时代码、可视化效果以及 HTML 或更紧凑的 Markdown 语法格式化文本的文档。通过结合 Markdown、代码和输出,我们可以在一个地方自动生成漂亮的报告、信息丰富的分析以及工作中的概念验证。代码片段可以独立运行,但数据在不同代码块(称为单元格)之间持续存在。

在编写和测试项目中单独函数时,我经常使用笔记本,因为我可以一次配置变量,然后反复测试该函数,而不需要重新运行其他任何代码。正如你将看到的,我在编写这些章节时也广泛使用了它们,以生成大多数图表和代码块。能够独立运行某些代码片段在我调整和渲染图形时非常方便。

总结

规划和构建一个稳固的 Python 环境可能不是世界上最令人兴奋的阅读话题,但确保所有工具都到位并准备好工作,将使得进入书中的其余部分变得更加简单。无论你选择了使用 Anaconda 的简单路径,还是选择了使用 virtualenv 的更高级路径,你现在应该已经有了一个独立的区域,可以跟随接下来的项目中的代码。下一章,我们将通过整理整个书中使用的编程和数学语法来完成最后的整理。如果你已经非常熟悉 Python,并且能区分你的 sigmas 和 deltas,可以直接跳到第三章。

第四章:编程与数学约定

现在你已经有了一个工作环境,我们来讨论一下我们将使用的编程语言。本书假设你对编程概念如循环、变量、条件语句和函数有基本的了解,因此本章并不打算作为 Python 的全面介绍。相反,它旨在说明一些更细微的要点,这些要点将帮助你理解本书中的示例以及其他教程中的示例。当然,本书的编程内容主要集中在实现研究论文中的数学概念,因此我们对这类材料中使用的符号有一个共同的理解同样非常重要。

语法构造

虽然本书并不打算作为 Python 编程的入门教材,但在深入之前,你应该熟悉一些有用的语法构造。平衡使用这些高级特性非常重要,以保持代码的可读性和易理解性。在本书的项目中,我使用了本节中描述的构造,以简化代码。如果你还在学习 Python,刚开始可能会觉得某些语法有些令人生畏,但一旦你在自己的代码中使用这些构造几次后,你会惊讶于没有它们你是如何完成工作的!

列表推导

当你需要通过遍历一些代码来创建一个值的列表(或字典)时,理解构造非常有用。最简单的使用场景是对列表中的每个元素应用一个函数。例如,假设你有一个字符串列表,叫做names,你想将它转换为大写。你可以使用类似下面的循环:

names = ["bob","mike","tom","mary"]
names_2 = []
for n in names:
    names_2.append(n.upper())

但是,这种方法也有一些缺点。首先,它需要两个列表:一个用于输入(names),一个用于输出(names_2)。当你有非常大的复杂对象列表时,像这样为了简单的改变而复制整个列表到内存是低效的。你可以添加更多的代码,用names_2的内容覆盖names,然后显式地释放names_2所占用的内存,但那样做既麻烦又让代码显得混乱。这引出了第二个问题:代码占用了比必要更多的行。在复杂的应用中,你可能需要维护数百个函数。保持代码简洁能在编写和修改大型代码库时节省时间。你可以将整个过程浓缩成一行代码,像这样:

names = [n.upper() for n in names]

在列表推导式中,第一个变量定义了结果列表中将存储的内容。在这种情况下,n 是我们要转换为大写的字符串,因此是 n.upper。我们通过右边的 for 语句定义 n 的值,该语句会遍历 names 列表并选择每个 n。然后,结果列表会直接赋值给 names 变量,覆盖其先前的值,并自动释放循环所使用的内存。我们可以在语句末尾添加一些条件逻辑来过滤结果。例如,假设我们想根据每个字符串的首字母是否是 m 来过滤 names 列表。你可以像下面这样写一个循环块:

m_names_upper = []
names = ["bob","mike","tom","mary"]
for n in names:
    if n.startswith("m"):
        m_names_upper.append(n.upper())

现在,在 for 循环的每次迭代中,我们有一个条件语句来检查字符串是否以 m 开头,如果是,它会将大写版本追加到 m_names_upper 列表中。然而,这种方法和之前的循环示例有相同的缺点,而且占用了更多的空间!这段代码也可以通过列表推导式缩短为一行:

m_names_upper = [n.upper() for n in names if n.startswith("m")]

我们在列表推导式语句的末尾添加了一些条件逻辑来过滤结果;同样,只有当 nm 开头时,我们才会添加 n.upper

这两个例子会产生相同的输出;哪个更简单或者更容易解释是可以争论的,所以你选择的方式通常是个人偏好的问题。请注意,你可以在列表推导式中添加 else 条件来控制 if 返回 False 时发生的情况。在条件逻辑为 False 的情况下——例如,当一个函数期望两个列表具有相同的长度,以便对其中的值进行配对操作——你可能希望向输出列表添加一个静态值,而不是完全排除该元素。假设我们想要将所有名字不以 m 开头的人重命名为 marcus。使用传统的循环结构,这需要在 if 块之后添加一个 else 块来处理插入值。我会跳过这一部分,因为我们都见过 Python 中的 else 块(希望如此),而我们现在讨论的是列表推导式。向列表推导式添加额外的 else 子句会改变语法,使得 if...else 逻辑出现在 for 循环逻辑之前,像这样:

m_names = [n if n.startswith("m") else "marcus" for n in names]

如你所见,if 语句紧跟在要存储的变量 n 后面。然后,你添加 else 条件,它定义了在 if 语句为 False 时将添加到输出列表 m_names 中的值。在这个例子中,如果一个名字以 m 开头,它将被添加到输出列表中;否则,字符串字面量 marcus 将会被添加。

列表推导式有许多更实际的用途,你将在本书和其他示例的代码中看到它们的身影,因此最好熟悉它们以及它们如何转化为更传统的循环代码结构。同样,理解列表推导式的局限性也很重要。如果你的条件选择逻辑比较复杂,你可能需要考虑将其抽象为一个单独的函数。然后,你可以在列表推导式中使用该函数,并将其应用于每个元素(这种方法你也会在本书中看到)。列表推导式本身的唯一真正限制是,你只能应用一个条件语句,并且每次迭代结束时的输出必须是可以包含在 Python 列表对象中的内容。

字典推导式

字典是 Python 中最常见的数据结构之一。它们用于从简单的键/值配对到处理复杂的用户定义类等各种场景。幸运的是,字典也非常容易使用,我们刚才讨论的关于列表推导式的很多概念同样适用于定义字典对象,尽管你在实际应用中可能会遇到的例子较少。作为说明,考虑以下代码,它将一个键的列表与一个值的列表合并:

keys = ['Red','Blue','Green','Yellow']
values = [1,2,3,4]
out = {}
for i in range(len(keys)):
    out[keys[i]] = values[i]

再次强调,字典推导式的强大功能使我们能够将这段代码缩短为一行:

out = {keys[i]: values[i] for i in range(len(keys))}

你可能会注意到,列表推导式和字典推导式的语法之间只有少数几个差异。Python 的开发者这样做是为了使它们之间的关系更容易理解。如果你理解了列表推导式,那么你就理解了 90% 的字典推导式。需要记住的两点是:字典是使用大括号 {} 来定义的,而不是方括号,并且 for 关键字前的左侧部分表示一个键和值,用冒号分隔。在这里,我们将键定义为名为 keys 的列表中的第 i 个值。我们通过使用相同的索引 i 从第二个列表 values 中为该键分配值。然后我们定义一个 for 语句,使用 range 函数循环遍历从 0 到 keys 列表长度之间的整数值。这是一个假设 keysvalues 列表长度相同的函数示例。如果 keys 的长度大于 values,当尝试访问 values 中一个不存在的索引时,代码会引发错误。

你也可以使用一个函数来定义键变量和值变量,像这样:

out = {keys[i].upper(): float(values[i]) for i in range(len(keys))}

在这个示例中,我们修改了之前的代码,使所有的键都被转换为大写字母,所有的值都被转换为float。当然,我们可以将这些函数替换为任何符合我们需求的函数。只要每个函数的返回结果能够作为常规 Python 字典中的键或值,就可以使用该函数。与列表推导式一样,这里的条件逻辑也有一个注意事项。如果需要更复杂的逻辑,应该考虑将键和值的定义提取到一个独立的函数中,像这样:

out = {k: v for k,v in my_logic(keys)}

这里的键和值是通过函数调用my_logic(keys)的返回值来定义的。这个函数的具体作用并不重要;重要的是,函数返回的是一个包含元组(或嵌套列表)的列表,每个元组包含两个元素。每个元组中的第一个元素将被视为键k,第二个元素则是对应的值。这就是字典推导式的小秘密:它们其实只是伪装成字典的列表推导式!你可能已经意识到这一点,因为range函数也会生成一个列表。当你希望对列表中的每个项执行某个函数,并将结果作为字典保存,关联每个项及其函数调用结果时,这是一个非常方便的快捷方式。

打包与解包

Python 内建的zip函数返回一个元组迭代器,其中第i个元组包含从每个传入参数中提取的第i个元素。假设你想将之前存储在names中的姓名列表与分别存储在keysvalues中的颜色和数字列表结合起来。zip函数将允许你高效地生成这些组合,以元组列表的形式,并且只需一行代码:

a = zip(names, keys, values)
print(list(a))

变量a现在持有一个zip对象,当它被转换为列表时,将包含像("bob","Red",1)这样的三元组。需要注意的是,传入列表的顺序很重要,因为从左到右的处理顺序是保证的。还需要留意,迭代器会在最短的输入耗尽时停止。解包是zip函数的逆操作,但它是 Python 的一种行为,而不是一个需要调用的函数。例如,你可以像下面这样解包zip对象a中第一个元组的三个值:

person, key, value = list(a)[0]

这一行代码将a中第一个元组的三个值分配给等号左侧的三个变量(从左到右的顺序)。你必须确保变量的数量与元组中的元素数量一致,否则代码将抛出异常。打包和解包在你需要在应用程序中传输数据时非常有用。与其定义多个变量来保存三个输出列表,我们可以应用zip函数来返回一个单一的zip对象,这样可以保留三个列表之间的关系。然后,你可以遍历结果,并根据需要将值解包到不同的变量中。

你看到的其余代码将是标准的 Python 代码。当我们在项目中使用特定库时,我会指出相关的语法。

接下来,我们将深入探讨数学符号的精彩世界。正如你将看到的,理解所使用的符号在高级数学中扮演着至关重要的角色。它提供了一种灵活的速记形式,使得公式更容易记住。不幸的是,与编程类似,这些符号可以具有多重含义。数学符号通常具有双重性质,因为它们既可以表示正在进行的数学操作,也可以表示执行这些操作的变量。

数学符号

数学符号是一个棘手的领域,尤其是当你试图在文本中涵盖多个领域时。原因是许多数学符号是重载的,这意味着它们有许多可能的含义,要知道适用的是哪一种含义,就需要查看上下文。一个完美的例子是希腊字母θ。在机器学习文献中,θ通常指的是为一组数据计算出的特征权重集。这个“假设”函数常见于机器学习的前几周,通常会讲解线性回归模型。

然而,在几何学和三角学中,θ通常用作“角度”变量——例如,在图 2-1 所示的勾股定理中。

图 2-1:使用θ表示角度(度数)的勾股定理

在可能的情况下,我坚持使用该领域内主流材料中使用的符号。在符号出现重载的情况下,我会附上意图含义的解释。

布尔符号

在应用数学中,你会看到最常用(也是最常被忽视)的一项概念就是布尔代数。布尔是一种原始数据类型,在任何给定时刻,它只能承载两个可能的值之一。该值由与之相关的逻辑语句决定。例如,我们可以问两个数字,xy,是否相等。这个问题的答案始终是“是”或“否”,取决于输入值。永远不会出现第三种答案。

布尔符号通常以真值表的形式表示。为了节省空间,我没有在本书中包含完整的真值表;你可以在网上找到许多关于这些表的优秀参考资料。我们将重点讨论用于编写逻辑语句的符号以及如何解释它们。表 2-1 涵盖了每个主要的布尔代数符号及其直观意义,并提供了将逻辑应用于语句的示例。

表 2-1:布尔逻辑示例

符号 含义 示例
与:A ∧ B 只有当 AB 都为 True 时,整个语句才为 True。在代数中,A × B 或者仅为 AB 猫是哺乳动物飞机会飞True,因为两个语句都是 True。如果任何一个语句为 False,整个语句就会是 False(等于 0)。
或:A ∨ B 只要 AB 或者 AB 中的任何一个为 True,整个语句就是 True。在代数中,A + B 猫是狗鸟是猫False,因为 AB 都不是 True。如果其中任何一个语句改为 True,结果就会改变。
非:¬ A, À 当语句 AFalse 时,该语句为 True。它是一个反转器,输出其输入的相反值。 如果 A 代表语句 猫是鱼,这可以写作 非猫是鱼非猫是鱼True,因为语句 猫是鱼False
异或:A ⊕ B 如果 AB 其中之一为 True,但 AB 都不是 True,则该语句为 True。在代数中,( AB̀ ) + ( ÀB )。 衬衫是红色的 异或 衬衫是蓝色的True,如果衬衫是红色或蓝色,但不是同时是红色和蓝色。

这些看似简单的组件可以通过链式布尔语句来描述人类已知的最复杂的系统。了解布尔表达式中的操作顺序和括号的使用非常重要。运算符优先级与传统代数相同,但功能较少。括号内的运算优先进行,然后是所有的 AND 条件(乘法),最后是 OR 语句(加法)。否定操作在操作的最终步骤中处理,因此无需特别处理。传统上,我们从左到右评估多个括号。

大多数运行时环境,比如 Python 的解释器,会在一旦语句的真假能够确定时就停止评估。它们不会继续检查那些已不再影响结果的其他语句。例如,逻辑语句

( 猫会飞 ∧ 狗会吠 )

会首先检查 猫会飞 语句。由于这是 False,第二个语句的值不会影响 AND 语句的结果。Python 会判断整个语句为 False,而无需检查 狗会吠 的条件。为了理解 Python 如何解析更复杂的逻辑语句,我们来看另一个例子。语句

( 猫叫 ∧ 狗吠 ) ⊕ ( 鱼会游 ∧ ¬鸟会飞 )

在解释器中按如下方式评估。首先,括号内的语句

( 猫叫 ∧ 狗吠 )

被评估。它为 True,因为它包含的两个语句都为 True(猫会叫且狗会吠)。由于第二组括号仍然可能影响结果(XOR 操作总是要求我们评估两边),

( 鱼会游 ∧ ¬鸟会飞 )

接下来进行求值。鸟飞的否定使得第二个陈述为 False鸟飞True,所以 NOT 鸟飞False)。现在我们求解 XOR 操作

( AB̀) + ( ÀB )

其中 A 是第一个括号组的结果,B 是第二个括号组的结果。我们可以通过递归应用操作符优先级轻松解决这个问题。

( 1 × 0̀) + ( 1̀ × 0 ) = ( 1 × 1 ) + ( 0 × 0 ) = ( 1 + 0 ) = 1

我们将 1 解释为整体陈述

( 猫叫 ∧ 狗叫 ) ⊕ ( 鱼游 ∧ ¬鸟飞 )

True,这符合我们的预期,因为 XOR 运算要求条件中恰好有一个为 True,结果才为 True

集合符号

在数学中,我们通常想表示一组对象,而不是单个对象。例如,我们可能会说,所有去同一所学校的学生构成一个 集合。每个学生在集合中只会出现一次。

我们可以表示学生集合 S。(我将遵循使用大写字母的英文变量名表示集合的惯例,尽管有一些例外)。这定义了哪些元素是或不是集合的一部分。这通常称为 成员规则。现在我们有了成员的定义,我们可以表示元素 i 是否在集合中 (iS),或者当然,它不在集合中 (iS)。在这个例子中,iS 与“所有不是该学校学生的东西”在语法上等价。当处理多个集合时,我们可能感兴趣的是哪个集合包含某个元素 (Si)。假设我们为区内每个学校创建了一个集合,那么这就相当于问,“某个学生在哪所学校就读?”表 2-2 描述了本文中使用的符号。

表 2-2:集合符号示例

符号 含义 示例
i ∈ S 布尔值:集合 S 中的元素 i 3 在奇数集合中为 True。4 在奇数集合中为 False
i ∉ S 布尔值:集合 S 中不包含元素 i 3 不在奇数集合中为 False。4 不在奇数集合中为 True
S ∋ i 布尔值:集合 S 包含元素 i 奇数集合包含 7 为 True。汽车集合包含自行车为 False
A ∩ B 乘积:i ∈ A 且 i ∈ B。所有同时在集合 A 和集合 B 中的元素。 会生成一个集合,包含所有同时满足 AB 的元素。如果 A 是所有动物的集合,B 是所有哺乳动物的集合,那么猫可以出现在这个乘积中,因为它既是动物集合中的元素,又是哺乳动物集合中的元素。
A \ B 乘积: ( i ∈ A 且 i ∉ B ) + ( i ∈ B 且 i ∉ A )。所有在集合 A 中但不在集合 B 中的元素,加上所有在集合 B 中但不在集合 A 中的元素。 所有与安全相关的、不涉及医疗技术的主题,加上所有与医疗技术相关的、不涉及安全的主题。
A ∪ B 并集:i ∈ A ∨ i ∈ B。所有在集合 A 中或集合 B 中或两个集合中都有的项目。 所有是加油站或杂货店或同时是加油站和杂货店的地方。
p ⊂ S, p ⊆ S 布尔值:子集 p 中的所有项目都在超集 S 中。如果 p 可以包含 S 中的所有成员,则使用 ⊆。 门锁的子集是所有安全设备超集的一部分,这是True
p ⊄ S 布尔值:子集 p 中的一个或多个项目不在超集 S 中。 [梨子,葡萄,岩石] 不是食物的子集,这是True
∀ p ∈ S F 对于集合 S 中的所有项目 p,应用函数 F 对于房间里的所有人,说“你好”。
m02002 或 nCk n 个元素中选取 k 个元素的无序组合的数量。读作“从 n 中选择 k。” 从 [Ron, Tom, Ann] 中选择两个名字:m02003

最后,有一些保留集合由于在文献中频繁出现,已经被赋予了标准化符号。你可以在表 2-3 中找到它们的列表。

表 2-3:保留集合

符号 成员规则
空集。常在算法中作为参数使用(例如广度优先搜索)。
所有整数的集合(1,2,3,……) − ∞ 到 ∞。也可以用于具有定义成员参数的整数子集(例如所有 3 的倍数)。
所有实数的集合(0.25,1.0,2.3,……)。通常仅使用 0.0–1.0 以及一个缩放因子。

属性字符

特殊属性符号是数学家喜欢对符号进行多重赋义的另一种情况。它们在公式中用于表示变量、函数输出、集合等的特殊条件。它们还用于区分可能共享一个字母(暗示它们相关)的相关变量。例如,(yŷ) 常用来表示某个回归函数的实际值与预测值之间的差异。变量 ŷ 表示预测值,差异越接近 0,表示预测越准确。这种表示法让我们直观地理解这些变量之间的关系,同时通过属性字符区分出特别关注的变量。

当文本中使用属性字符时,它将伴随一个描述,说明该字符在该上下文中的预期含义。

希腊字母和函数

最后,让我们讨论使用希腊字母表示不同变量、函数等的方式。我已经提到过其中一个符号,theta(θ),以及它的解释是如何根据上下文来决定的。你可能已经熟悉其他一些符号了。有些符号会比较固定使用,比如 pi(π),它总是表示圆的半径的一半。其他符号,比如 alpha(α),则会使用得更加自由。为了保持清晰性,我们将讨论这些符号在包含它们的每个公式中的含义。表 2-4 列出了使用符号缩写表示的一些常见函数。

表 2-4:函数表示法示例

名称/符号 常见解释 示例
ABS |A| A 的绝对值。也可以是向量或数组的长度(数组中包含的项数)。 | [ a,b,c ] | = 3 或 | 3 − 5 | = 2
SUM m02004 对函数 Fij 执行若干次的和。 m02005
PROD m02006 对函数 Fij 执行若干次的乘积。 m02007

摘要

学会解读这些符号,并记住如何应用它们,是概念验证工程中最具挑战性的部分。从现在开始,我们将进行的数学内容相当简单。如果你已经完成了代数课程,并且知道如何进行加法、减法、乘法和除法,那么接下来的项目你应该能够轻松理解。

在接下来的章节中,我们将开始讨论数学理论的重要领域,并构建项目来证明它们的实用性。每个理论章节和附带的项目旨在说明安全研究人员可以立即应用在工具开发中的一些理论。这些内容并不是为了成为某一理论或特定安全话题的全面论述。希望通过这些项目的完成,你能开始看到应用编程数学概念对安全工作和日常生活的巨大影响。

第五章:使用图论保障网络安全

图论是安全分析师工具箱中一个强大但常被忽视的工具。是一种数学结构,显示了事物之间的关系(称为连接),这些事物被称为节点顶点,而图论提供了一套用于分析这些不同、通常相互关联的关系的算法。尽管安全是一个技术性很强的话题,但其核心依然是关于关系:计算机与网络、用户与系统、信息片段之间的关系,等等。通过将计算机网络或社交网络建模为图,你可以检查关系的组成,例如,确定哪些计算机对一个企业的通信至关重要,或哪些员工最可能转发垃圾邮件,并且会转发给谁。了解哪些节点(机器或员工)带来最大风险,能够帮助你智能地分配安全资源。

本章首先讨论图论在信息安全中的多种应用,然后介绍图论本身的理论。我们将涵盖图的类型,如何高效地在 Python 中创建它们,以及你可以在它们上执行的一些有趣的测量。接下来的第四章到第六章将带你逐步应用你在这里学到的内容,分析计算机网络和社交网络,这两种网络是你作为安全工程师最常遇到的类型。我们将回答诸如哪个计算机在网络中接收了最多的数据,哪个成员在小组中最具影响力,以及信息可能如何在社交网络中传播的速度等问题。

图论在安全应用中的作用

在我们讨论如何实际应用图论之前,让我们先看看图 3-1 中的一个简单旅行图例。

图 3-1:旅行图

如前所述,图是由节点和边定义的。在这个例子中,节点(圆圈)代表主要城市的机场,边(箭头)代表两个城市之间机票的费用。这样的图可以帮助你节省旅行成本。例如,如果你想从西雅图到纽约,你可以先飞从 SEA 到 LAX,再飞到 MIA,最后飞到 JFK,总费用为 396.00 美元。你也可以选择从 SEA 飞到 ORD,再到 JFK,费用为 198.00 美元,或者直接从 SEA 飞到 JFK,费用为 250.00 美元。

我不知道你怎么样,但我每次飞行时不仅仅考虑费用,还会考虑旅行时间。除了每次旅行的费用,你还可以利用这个图来确定两个城市之间的最少中途停留次数。停留次数越少,旅行的时间就越短。正如你所看到的,即使是一个简单的图也可以包含大量的信息。

在分析计算机网络时,攻击者和防守者的第一步是了解“地形”。这意味着,在构建出周围可用资源的图形之前,他们无法开始任何攻击或防御工作。创建这种图形的一种方式是将计算机定义为节点,将网络连接定义为边;这在大多数网络图中很常见。在接下来的项目中,我们将从原始数据包捕获中建模一个计算机网络。在这个定义中,节点将是单独的计算机,边将表示一台机器何时向另一台机器发送数据包。

同样,分析社交网络可以揭示关键人物和关系,比如那些会转发垃圾邮件的员工,或者犯罪组织中的重要成员。你可以利用这些信息来针对或保护网络中的成员(取决于你的工作)。例如,FBI 利用卧底特工获取有关有组织犯罪家庭的信息,然后建立一个社交网络图,确定关键人物,并尝试将其逮捕。如今,随着社交媒体的普及,任何有笔记本电脑的业余侦探都可以建立一个惊人精确的组织(或个人)社交网络图,并利用这些信息针对关键成员以实现自己的目的。

研究人员还应用图论,用它来绘制如蜂窝网络和云计算等技术。例如,学者们提出了应用最短路径算法(类似于限制图 3-1 中机场停留次数的方式)来选择穿越表示 5G 蜂窝网络的图形中的安全路线。这项研究分析了消息如何在网络的物理层(OSI 模型)中从一个点传播到另一个点。^(1) 在第四章中,我们将在从数据包数据绘制计算机网络时使用类似的分析模型。其他现代研究则集中于绘制云中托管的组件之间的逻辑关系。通过映射代码使用和典型的虚拟机管理程序负载活动,科学家们提出了一种正式的方式来描述虚拟化平台的云安全问题。^(2)

图论也应用于开放源代码情报(OSINT),简而言之,它通过收集公开可用的信息来获取目标的情报。一个名为 Maltego 的应用程序会爬取公开网页,寻找相关的术语、电子邮件地址、地点、机器以及其他细节,并创建一个图形,显示它们在线上的出现位置,如图 3-2 所示。在 2017 年的 DEF CON 年度信息安全大会上,Andrew Hay 做了一场关于图论在 OSINT 应用中的精彩入门演讲。

图 3-2:图论在情报收集中的应用

像 Maltego 这样的应用程序将这些逻辑上不同类型的网络融合到一个图中,从而得出非常有趣的洞察。在一个例子中,我的团队能够定位到两个不同论坛用户之间的隐秘通讯渠道。尽管这些论坛由不同的公司运营,但它们托管在同一服务器上。用户 A 加入了 X 网站,而用户 B 加入了 Y 网站。然后,通过操控论坛软件,两名用户能够利用本地文件的读写操作,在底层服务器上传递消息。如果我的团队只检查了社交网络连接,我们可能会被难住,但当我们将社交网络信息和底层机器网络的信息结合起来时,我们意识到这两个账户可以访问相同的硬件。当然,你不必依赖其他人的工具;一旦你了解了内部工作原理,你可以制作自己的 OSINT 收集工具,配上既美观又实用的图形显示。

图还可以用来描述如何通过采取某些行动从一个状态过渡到另一个状态。例如,你可以通过移除门上的锁从相对安全变为完全不安全。在这个定义中,安全和不安全被称为 状态,而移除锁则是将你从一个状态转换到另一个状态的 动作,称为 过渡。图 3-3 显示了这样一个图,称为 状态机图,它描述了攻击者在环境中移动的潜力。第六章 将详细讲解状态机。

你可以这样解释这个图:如果你在互联网上,想要接管目标组织中的一名员工的系统,你可以尝试对其客户服务团队进行钓鱼攻击。当你获得一名愿意配合的员工后,你将向他们发送一个远程控制的恶意载荷。然后你将进入该员工的系统,但你可能仍然需要执行某种特权提升操作,才能完全接管系统。你还可以看到,这只是你可以采取的实现目标的一个路径。

图 3-3:状态机图

现在你对图论的应用有了大致了解,让我们来讨论一下它背后的数学原理。

图论简介

一个图 G 包含节点集 V 和边集 E。信息通过一组不重复的边在节点之间传递,这组边将节点连接在一起,称为 路径。一个节点可以将信息转发给任何与其直接相连的节点;接收节点是发送节点的 邻居。按照惯例,我将边表示为一个元组 (u, v),其中 u 是源节点,v 是终端节点,且 uv 都属于 V 且唯一(不等价)。我们可以用集合表示法写成如下:

E ⊆ ( u, v ) ∈ V 2 ∧ u ≠ v

有时候图中的一条边会指向同一个节点(这打破了我的uv假设);这被称为自环。例如,如果你创建一个包含递归函数的程序中的函数调用图,那么会有一条边从递归函数离开,并直接指向它本身。自环并不常见,但一旦出现,它们会使分析变得复杂,需要专门的算法,因此我建议在你对图论的基础知识非常熟悉之前,尽量避免处理自环。

根据图的类型,边可能是双向的(无向图)或单向的(有向图)。如果通信的方向对当前问题很重要,就使用有向图;否则,使用无向图。在实际应用中,无向图通常处理起来更快,因为你假设(u, v)=(v, u)。不过,很多问题描述需要有向边。例如,在图 3-1 的旅行图中,从 LAX 飞往 MIA 的费用与从 MIA 飞往 LAX 的费用不同,因此我们需要在这两个节点之间使用有向边来捕捉方向信息。

一条边可能包含边属性,即除了它连接的两个节点之外的额外信息。节点也可以包含额外的信息,称为节点属性。当这些属性用于排名节点或边时,它们被称为节点或边的权重,而包含权重的图称为加权图。在某些情况下,你甚至可能需要为连接两个节点添加多个边(称为边的多重性),以考虑不同的边属性或权重。我将在本章后面单独讨论边的多重性,但现在我们可以扩展图 3-1 中的旅行图,来做一个简单的示例。假设我们发现有多趟航班从 SEA 飞往 LAX。我们可以选择为每个额外的航班添加一条边,并将其费用作为权重。为所有城市对添加这些边会让我们了解哪些机场有更多的旅行选择。我们将在接下来的章节中使用多条边、边属性和加权图,以有意义的方式推动我们的研究。

简单图是无权重、无向的图,且不包含自环或边的多重性。非简单图(或不太常见的伪图)包含自环或多条边,它们占大多数实际中你会遇到的有趣图的类型。

GE 的一个非空子集,形成一条路径,使得路径的第一个节点与最后一个节点相同,且路径中没有其他节点重复。这是说,路径形成了一个闭合的循环。自环是图环的一种特殊情况,其路径长度严格为 1。有向图 是指至少包含一个图环的图。如果图中没有环(没有闭环),则该图是 无环 的。

在深入讨论理论之前,让我们先来看看如何通过编程构建这些图对象。在下一节中,我们将介绍当前 Python 图形的事实标准库 NetworkX。使用这个库中的工具将帮助你构建本书中的示例,并按自己的节奏玩转理论。NetworkX 的文档也是理解每个函数背后理论的一个很好的参考。

在 NetworkX 中创建图

你可以使用 NetworkX(它包含大多数图算法的实现)和 Pyplot(Matplotlib 库的一部分)来生成并显示一个无向图。示例 3-1 创建了一个有七个节点和六条加权边的图,并将其显示出来。

❶ import networkx as nx
from matplotlib import pyplot as plt

❷ G = nx.Graph()  # Create the default Graph object
❸ G.add_node('f') # Adds a node manually
G.add_node('g') # Adds another node manually
❹ G.add_edge('a', 'b', weight=0.6) # Will add missing nodes
G.add_edge('a', 'c', weight=0.2) # and connecting edges
G.add_edge('c', 'd', weight=0.1) # Weight is one type of edge attribute
G.add_edge('c', 'e', weight=0.7)
G.add_edge('g', 'c', weight=0.8) 
G.add_edge('f', 'a', weight=0.5) 
❺ pos = nx.layout.spring_layout(G, seed=42) # Try to optimize layout 
nx.draw(G, pos, with_labels=True, font_color='w')
plt.show()

示例 3-1:创建一个基本的加权无向图

首先,我们导入构建和显示图形所需的两个库 ❶。(将 NetworkX 别名为nx,将 Pyplot 别名为plt,这是网络示例中的常见约定。)然后,我们使用 NetworkX 的 Graph 构造函数 ❷ 创建一个基本的无向图。以这种方式定义的图会返回一个空图(没有节点或边)。

为了手动定义图的结构或 拓扑,我们可以添加节点或边。要向图中添加节点,我们使用 graph.add_node 函数 ❸,并传入一个参数作为节点的标识符(例如,在查找时)。在这个例子中,ID 是字符串字面量 f,但 ID 可以是任何能作为 Python 字典键的对象(例如元组)。graph.add_edge 函数接受两个节点和可选的边属性作为参数,直接将边添加到图中 ❹。如果 ab(或者两者,如此例)在图中不存在,NetworkX 会在添加边之前帮助你添加缺失的节点。对于有向图,你传递给 graph.add_edge 的节点顺序决定了边的方向:边从第一个节点开始,到达第二个节点。

图形的真正优势在于它们的视觉解读,因为人类往往能在信息中通过视觉发现模式,这是其他方式难以做到的。NetworkX 支持多种显示图形信息的选项,包括 Matplotlib 和 Graphviz。在这个示例中,我们使用 NetworkX 内置的布局函数之一 nx.layout.spring_layout ❺ 来布局图形,它利用弹簧运动的物理模型来定位节点。节点的初始位置是随机生成的,但你可以传入 seed 参数使图形可复现,这在你想与他人分享研究结论时非常重要。最终的节点位置存储在字典 pos 中,结构为 {node ID: (``x-coordinate``, ``y-coordinate``)}。函数 nx.draw 使用这些节点位置创建一个绘图对象,Matplotlib 显示结果图形。nx.draw 的附加参数 labelsfont_color 控制图形的外观,见 图 3-4。

图 3-4:一个无向图

如果你移除 seed 参数并重新运行代码,图形可能会有所不同,但它与 图 3-4 中的图形在数学上是等效的。

现在我们有了一种编码和可视化图形的方法,让我们来看一些你可以在分析中使用的有趣度量。

发现数据中的关系

在本节中,我们将考察一些最常用的图形属性,这些属性能帮助我们洞察数据中的潜在关系。这些属性以统计关系的形式表现出来,例如两个节点之间可能路径数与图中总路径数的比率。通常我们关心的是了解哪些节点与其他节点隔离,节点之间的最短或最长可能路径是什么,以及从特定起始节点可以到达多少个不同的节点。有数十种可能的图形属性可供探索,但有些属性仅适用于某些类型的图形,而另一些则是这些更一般属性的特定应用场景。这里描述的属性将为你理解接下来三章的项目提供所需的所有知识,但这并不是一个完整的列表。

衡量节点重要

在安全性中,一个关键概念是衡量不同资产的重要性,无论是人类还是机器,以及破坏它们可能对整个组织运营产生的影响。为此,我们需要一种衡量哪些节点处于关键位置的方法。紧密度衡量两个节点相对于图中其他连接的连通性。

当你在图中的所有节点上应用接近度时,你实际上是在测量每个节点的某种中心性(大致上是“重要性”)。图中定义了几种类型的中心性。选择使用哪种类型取决于你试图分析的网络的行为和结构。^(3)有时你无法预先知道哪种中心性度量最适合你的问题。在这些情况下,先从较简单的度量(如接近中心性)开始,然后再尝试其他更复杂的度量。我们将介绍两种中心性:介于中心性和度数中心性。

寻找促进连接的节点

介于中心性将连接其他节点的节点视为图中更中心的节点。考虑一个计算机网络,如图 3-5 所示,其中一些系统充当代理,将用户连接到数据库。

图 3-5:一个简单的代理网络

介于中心性将灰色代理节点的评分大大高于其他任何节点(如用户和数据库),因为七个用户中的五个必须通过两个代理之一才能到达任一数据库。顶部的浅灰色圆圈位于六条路径之间(3 个用户 × 2 个数据库 = 6 条路径),底部的深灰色圆圈位于四条路径之间(2 个用户 × 2 个数据库 = 4 条路径)。此外,由于这五个用户必须通过各自的代理才能访问数据库,中心性进一步得到增强。

从正式的角度来看,节点u的介于中心性是从节点s到节点t的所有最短路径的比率之和,这些路径必须通过节点u(记作σ[(][s][,] [t])),与节点s和节点t之间所有最短路径的总数(记作σ[(][s][,] [t][)])相比,适用于所有sut的路径。将这一切汇总起来如下所示:

介于中心性分数可以归一化到图中节点的数量G。归一化函数对于无向图为 2 / ((n – 1)(n – 2)),对于有向图为 1 / ((n – 1)(n – 2))(其中n是图中节点的数量)。这种差异源于方向性对归一化的影响。对于无向图,在两个节点之间添加一条边会影响这两个节点的介于中心性分数,因此它的影响是有向图中相同边的两倍(后者仅影响一个节点,即源节点)。计算具有五个节点的有向图和无向图的归一化分数如下所示:

与某些其他中心性度量(如接近中心性)不同,NetworkX 中的介于中心性是否归一化是可选的,可以通过布尔关键字参数normalized=True来指定。列表 3-2 展示了我们如何检索在列表 3-1 中生成的图的介于中心性分数。

b_scores = nx.betweenness_centrality(G, normalized=True)
nx.set_node_attributes(G, name='between', values=b_scores)
print(G.nodes["c"]["between"])

清单 3-2:在清单 3-1 中创建的图的中介中心性

该示例图的归一化结果应约为 0.8。在图 3-4 中,共有 15 条最短路径连接所有节点对(排除以c作为起始或结束节点的对)。在这 15 条路径中,12 条路径在某些点上经过c(12 / 15 = 0.8)。该示例的 Jupyter 笔记本展示了如何通过循环遍历节点对并计算包含目标节点的最短路径数量,手动计算中介中心性分数。

中介中心性在信息安全和网络分析中有许多应用,因为它表示一个节点在多大程度上促进了其他节点之间的通信。例如,在计算机网络中,一个具有高中介中心性的节点将对网络流量有更多的控制,因为更多的数据包最终会经过它。因此,中介中心性也可以用来识别在网络流量中进行检查的合适位置。另一个应用是理解社交网络中的关键故障点,我们将在第六章中进一步讨论。

测量节点连接数

中心性还可以通过节点拥有的邻居数量来衡量;这被称为度中心性。直观地说,度中心性偏向于那些与图中其他节点连接数量较多的节点。(中介中心性可以视为度中心性的一个特定衡量标准。)

对于无向图,度中心性计算为与节点u直接连接的所有节点的比例。你通常会看到节点u的邻居标注为Γ[(][u][)]。

记得在书本开头的数学入门部分,节点集合的绝对值(例如|V|)与集合中节点的数量相同。我们从V的长度中减去 1,以考虑到节点c不能是其自身的邻居。无向度中心性通过nx.degree_centrality来计算,如清单 3-3 所示。粗体部分显示了与清单 3-2 相比所需的少量更改。

**d_scores = nx.degree_centrality(G)**
nx.set_node_attributes(G, name=**'degree'**, values=**d_scores**)
**print(G.nodes["c"]["degree"])**

清单 3-3:带有来自清单 3-2 更改的度中心性(粗体部分)

这段代码的输出应约为 0.66,这意味着节点c与图中三分之二的节点是邻居。如图 3-4 所示,节点c有四个邻居,除去c,总共有六个节点可能是c的邻居。于是我们得到 4 / 6 = 2 / 3 = 0.66,这与nx.degree_centrality的结果一致。

对于有向图,度数中心性度量被分为两个部分。第一个部分涉及指向节点的边,称为入度中心性。第二个部分涉及从节点指向其他节点的边,称为出度中心性。每个度量的计算方法与度数中心性相同,只不过它只考虑符合指定方向的边的子集。我们将这些边的集合表示为

( u → ) = E( u, )

对于出度和

( u ← ) = E( ,u )

对于入度。清单 3-4 创建了清单 3-1 的有向版本,然后计算了图中每个节点的入度和出度中心性。

❶ **G = nx.DiGraph() # Create the default Graph object**
G.add_edge('a', 'b', weight=0.6)
G.add_edge('a', 'c', weight=0.2)
G.add_edge('c', 'd', weight=0.1)
G.add_edge('c', 'e', weight=0.7)
G.add_edge('g', 'c', weight=0.8) 
G.add_edge('f', 'a', weight=0.5) 
❷ i_scores = nx.in_degree_centrality(G)
❸ o_scores = nx.out_degree_centrality(G)
nx.set_node_attributes(G, name='in-degree', values=i_scores)
nx.set_node_attributes(G, name='out-degree', values=o_scores)
print(G.nodes["c"]["in-degree"], G.nodes["c"]["out-degree"])

清单 3-4:创建有向图以衡量入度和出度中心性

为了使图变为有向图,我们将生成器nx.Graph替换为nx.DiGraph ❶。然后,我们使用nx.in_degree_centrality ❷和nx.out_degree_centrality ❸来获得各自的度量值。代码的结果应该是两个值都为 0.33。如果你检查数据,你会发现节点c有两条入边和两条出边,总共有六条边。对于每个度量来说,计算公式是 2 / 6 = 1 / 3 = 0.33。如果你尝试在无向图上运行清单 3-4,你会得到一个NetworkXError类型的错误,因为入度和出度是特定于nx.DiGraphnx.MultiDiGraph对象的。

度数度量的家族允许我们在计算得分时指定信息流的方向,而接近中心性和介数中心性度量则假设了方向性。为了理解为什么这很重要,考虑分析与分布式拒绝服务攻击(DDoS)相关的网络流量。DDoS 攻击通过将超出目标机器处理能力的流量发送到网络或特定目标,从而阻塞合法用户的访问。随着数据包从一个系统传输到另一个系统,它们在图中形成有向边。在目标节点处,可以看到入度中心性突然增加,这可以让脚本自动检测并响应这一威胁。通过包括信息流的方向,通常可以为你的图提供更有意义的背景。

分析团体以追踪关联

调查人员使用团体分析来追踪不同群体之间的关联,这些群体通常不会主动提供成员名单。通过收集谁与谁在交流(有时还包括何时交流),你可以发现相互连接的簇,或者称为团体。从理论上讲,图G中的一个团体βV的一个子集,其中每个节点都与子集中的其他节点相邻。可以把它看作是一群已经彼此见过面的朋友,或者是一群所有计算机都已连接的计算机集群。一些资料可能将这些构造称为完全子图。图 3-6 展示了一个包含不同团体的无向图。

图 3-6:卡通人物图

一个节点可能出现在零个或多个团体中。例如,在图 3-6 中的图中,Tom 出现在三个团体中:Tom、Spike 和 Jerry;Tom、Butch 和 Jerry;以及 Tom、Squeek 和 Butch。列表 3-5 创建了来自图 3-6 的图,并计算了每个节点的团体成员得分。

clique_graph = nx.Graph()
clique_graph.add_edges_from(
    [
        ("Tom", "Jerry"),("Butch", "Jerry"),("Spike", "Jerry"),
        ("Spike", "Tom"),("Tom", "Squeek"),("Tom", "Butch"),
        ("Squeek", "Butch")
    ]
)
clq = nx.algorithms.number_of_cliques(clique_graph)
tot = nx.algorithms.graph_number_of_cliques(clique_graph)
for m in clq:
    print(m, (clq[m]/tot))

列表 3-5:创建图 3-6 中的卡通人物团体图

调用nx.algorithms.number_of_cliques可以统计每个节点所属的团体数,你可以用它来轻松找到所在团体最多的节点。为了找到图中团体的总数,我们使用nx.graph_number_of_cliques。然后,我们可以将每个节点的团体数和总团体数结合起来,创建一个归一化的评分,用于确定网络中的关键促进者。运行此示例代码的输出应为:

{'Tom': 1.0, 'Jerry': 0.66, 'Butch': 0.66, 'Spike': 0.33, 'Squeek': 0.33}

Tom 在每个团体中,Jerry 和 Butch 分别出现在三分之二的可能团体中,而 Spike 和 Squeek 只出现在三分之一的可能团体中。显然,Tom 是这个网络中最著名的成员。在社交网络中,如公司或有组织的犯罪集团,出现在最多团体中的成员对于促进操作至关重要。如果我们想要破坏这个组织的活动,去除 Tom 将大大有助于实现这一目标。你还可以通过衡量网络中的团体成员关系,识别那些充当网络中其他分隔部分之间网关的节点。

函数nx.algorithms.number_of_cliques查找每个节点所属的最大团体的数量——即所有节点互相连接的最大节点组。在无向图中,任何两个相邻的节点都可以视为一个团体,并且在任何图中,包含四个或更多节点的团体包含三个和两个节点的团体,因此,处理最大团体时考虑到了这些子团体。

你可以使用nx.find_cliques函数列举图中的所有最大团体,如列表 3-6 所示。

cliques = list(nx.find_cliques(clique_graph))
print(cliques)

列表 3-6:从有向图创建团体列表

结果是一个Generator对象,这是 Python 3 中内置的对象类型。你可以直接使用它,也可以将其转换为list。我们将在第五章中看到一个实际应用,通过使用nx.find_cliques查找团体,当我们从帖子构建社交网络图时。

确定网络的连通性

图可以是连通的或不连通的。连通图是指每一对节点(uv)之间都有某种连接路径(ρ)。因此,如果任何一对节点(uv)没有连接路径(ρ),则图G不连通的。判断一个图是否连通的唯一方法是检查每一对节点,看看它们是否不连通。我们可以使用集合符号和布尔代数来简洁地写出这一点:

一个布尔语句,如 ρ(u, v) ⊄ E,如果为真则返回 1,否则返回 0,因此这个方程实际上会计算所有断开节点对。在实际应用中,我们不需要继续搜索 G 中的所有剩余节点对,因为一旦我们发现一个缺失的边,就知道它是一个断开的图。然而,我们只能在检查了每一对节点并且没有发现断开的节点对时,才能说图是连通的。你可以自己通过图 3-1 中的图来判断它是连通图还是断开图。

图 3-7 展示了图 3-6 的图扩展成一个断开的图。

图 3-7:一个断开的图

断开的图由两个或更多不同的部分组成,这些部分称为连接组件(或简称组件)。正式来说,一个无向图 G 的连接组件 ϕi* 是一个子图,其中每对节点 (u, v) 都由路径 ρ(u, v) ∈ E 连接(对于第 i 个组件子图中的路径,标注为 ρ(ϕ[i], u, v))。此外,ϕ 中的节点不能与超集 V 中的其他节点连接。例如,图 3-7 展示的图有两个连接组件:一个包含卡通人物,另一个包含前乐队成员。

使用图的边来捕获重要细节

我们将要检查的最后一个图的属性是我之前提到过的边的多重性。这个属性在你知道如何利用它为分析带来的灵活性后非常强大。在许多实际应用中,比如下一章的包分析项目中,节点之间可能有多条边,这些边包含我们希望保留进行分析的有价值的信息。

例如,绘制 TCP 握手图需要在两个节点之间绘制多个定向边。连接的机器(也叫做客户端)向目标机器发送同步请求(一个 SYN 包),这在图中创建了一条从 uv 的定向边。目标机器随后会发送一个确认响应,并同时请求同步(一个 SYN-ACK 包),这会在图中创建一条从 v 回到 u 的定向边。(在无向图中,这个响应会被视为第一次边的重复。)最后,连接的机器发送给目标机器自己的确认包(一个 ACK 包),这会在图中创建第二条从 uv 的定向边。图 3-8 展示了两种不同的系统组之间的 TCP 握手数据所构成的相同图的两个版本。

图 3-8:比较单边图和多边图

左侧是一个标准的DiGraph表示法,它将两个节点之间的重复通信视为一条单一的有向边。仅通过检查这个图,你无法确定哪些节点参与了 TCP 握手。右侧是相同数据的MultiDiGraph表示法,它为每次通信发生保留一条边。通过检查这个图,你可以很容易看出节点c发起了与节点d的类似握手的交换。节点a也发起了与节点b的握手。

处理边的多重性有两种思路。第一种思路认为你应该根据它们的权重ω(以及可能的其他属性)将多条边总结为一条边,像这样:

如果组成边是无权重的,那么权重就是组成复合边的边的数量:

( u → v ) ω = | ( u → v ) | ∈ E

在处理有向图时,这种总结必须考虑到边的方向性。(如果你在边的属性中使用复杂值——例如范围,这需要专门的处理来进行总结——你最好在代码中实现自己的边总结定义。)

第二种思路是将每条边单独绘制,只有在分析时才总结这些边。这样做可以保留更多的底层结构。例如,考虑网络数据包中的时间戳信息。如果你像前面的例子那样将边总结为一条边,你就无法看到边创建的顺序。保留每条边使你可以按时间戳对它们的创建顺序进行排序,并寻找有趣的模式,比如边中的呼叫和响应对。

在图中处理边的多重性没有统一的对错方法。正确的方法通常是两种思路的结合,正如我们将在下一章看到的那样。

总结

图论的力量在于节点和边的灵活解释。节点代表的是人、计算机、城市,还是完全不同的事物?边是衡量物理距离,还是衡量无形的关系?这些问题的答案都是肯定的。不过要注意:这种感知自由是一把双刃剑。因为节点和边的定义并不严格,所以你可以创建一个图,其边和分析与现实之间没有任何有意义的关系。一个例子是使用代表计算机的节点和代表计算机所在城市之间物理距离的边。我们通常不会考虑互联网消息的传播距离是基于物理距离,而是基于消息在到达目的地之前需要经过多少个网络“跳”。在接下来的三章中,我将更深入地解释信息的不同解释的正当性,因为结果的含义,例如接近中心性,依赖于边的权重的含义,并需要一定的上下文才能理解。

这里涵盖的理论只是冰山一角。理查德·特鲁多(Richard Trudeau)所著的《图论导论》(Dover,2001)是一本非常优秀的资源。4 如果你在寻找更高级的讨论,可以参考马滕·范·斯廷(Maarten van Steen)于 2010 年出版的《图论与复杂网络:导论》一书。5 两本书都使这些话题易于理解,数学内容也易于跟随。如果你更关注安全方面,可以查阅 2020 年 12 月发表于《信息安全与应用期刊》上的论文“图论在安全中的应用:物联网网络中安全解决方案的定性部署”,该论文使用图论分析物联网网络设备的安全性,并确定监控设备流量的合适位置。6

在接下来的两章中,我们将通过研究计算机网络和人类社交网络,将这些理论概念付诸实践,学习哪些节点对网络至关重要,哪些信息正在交换,以及关于基础图结构的其他重要见解。最终的图论项目将在第六章中给出,你将获得模拟网络随时间变化所需的工具。一旦你理解了这些概念和解释,你所能获得的洞察力将使图论成为你分析工具库中最强大、最灵活的武器之一。

第六章:构建网络流量分析工具

对于我们的第一个项目,让我们从一个熟悉的主题开始。在安全领域,我们大多数人都至少花过一些时间分析数据包和监控网络流量。在本章中,我们将应用上一章讨论的概念——多边缘有向图、中心性和信息交换——来构建我们自己的网络流量分析工具。我们将使用捕获的网络数据来构建图形,计算一些指标以了解观察到的流量的属性,然后使用中心性度量来找出每台机器在做什么。

当我们谈论网络上的系统时,我们通常会想到它们最常见的使用场景。有些机器在网络上是为了提供文件服务,另一些则用于路由电话流量,还有一些则代表网络用户。通过弄清楚机器所扮演的角色,我们可以对每台机器产生的流量类型做出合理的猜测。

我们将使用信息交换比率来确定哪些机器正在创建和接收最多的某种类型的流量;这将帮助我们确定常规的流量水平,从而识别潜在威胁。最后,我们将开始捕获和分析周围的网络流量,使用一个概念验证工具从实时数据包捕获中生成图形。

让我们从一个示例网络图开始。

网络拓扑可视化

大多数基于 GUI 的数据包分析工具,如 WireShark 或 Zenmap,都允许你可视化网络拓扑,将数据包分析与图论结合起来推断网络结构信息。图 4-1 展示了我在研究网络上捕获的一个示例。

图 4-1:来自 Zenmap 的示例网络拓扑视图

回顾一下第三章,其中V表示所有顶点,E表示所有边;VE结合形成图G。在图 4-1 中,V中的每个节点代表一个在网络上生成流量的系统。E中的每条边是由观察到的数据包定义的通信路径。节点和边都有从解析的数据包字段中提取的属性;我们将利用这些属性进行进一步分析。从我的研究网络图中,我们可以推断出我的机器能够与位于同一局域网段的 11 台其他机器进行连接。

一般来说,我们可以将此图解读为展示我的研究网络中计算机之间的通信关系。我们可以使用这个关系图来推断出关于预期和意外行为的结论(例如,为什么你的咖啡壶会向你的打印机发送网络流量)。这在安全系统中非常有用,正如你所预料的那样。

大多数传统的网络监控工具依赖签名检测来分类恶意流量,在这种方式下,监控工具会扫描出表示威胁的行为,例如一个包的发送者 IP 是已知的指挥与控制服务器。通常,这些签名有两种形式。第一种,也是最常见的,是妥协指示符(IoC),它代表恶意软件执行的独特操作。顾名思义,IoC 可以帮助识别系统是否已被入侵。例如,如果一位研究分析员发现某个新的恶意软件变种在其设置过程中试图联系特定的 URL,那么网络管理员可以在他们的监控软件中添加一条规则,阻止访问该 URL 的流量,并发出潜在感染的警报。问题在于,IoC 方法依赖于对行为的先前了解,这些行为具有足够的独特性,可以高概率地识别感染并且低概率地误报。这些行为可能需要几个小时的人力研究来识别,而对于恶意软件作者来说,仅需几分钟时间即可在下一个变种中进行更改。需要跟踪的 IoC 数量令人震惊,而将它们应用到所有网络流量中,有时会导致系统变得极为缓慢。

我们可以通过第二种类型的签名检测来解决这个问题,恰当地命名为异常检测。这种签名依赖图论的元素来创建一组被认为是“正常”行为的网络指标。在实时流量分析中,如果这些值之一超出了定义的范围(通常包括一个可接受的偏差),操作员将会收到警报。通过将图论应用于网络流量,你可以设计出能够检测并应对异常流量的系统,而不依赖于以前见过的样本。然后,你可以进一步定义一个系统,根据生成的警报类型自动做出响应。

要将我们讨论的理论转化为异常检测系统,我们首先需要弄清楚如何将网络流量数据转化为可以分析的图形表示。我们还需要添加另一个库来提取我们想要的数据,并以有意义的方式将其输入到 NetworkX 中。

将网络信息转换为图形

我们将使用 Python 库 Scapy 从数据包捕获文件(即 pcap 文件)中提取信息,然后利用第三章中的概念基于这些信息创建图表。Scapy 是 Python 版本的瑞士军刀,用于数据包操作,提供了捕获、分析、构建和传输网络数据包的工具。Scapy 甚至可以用来快速定义全新的网络协议。Scapy 基于平台特定的数据包捕获库工作。在 Linux 上,这是 libpcap,通常在大多数现代 Linux 平台上默认安装;它在基于 BSD 的发行版和 macOS 上也默认安装,而在其他基于 Linux 的发行版上,通常也默认安装稳定版本。在 Windows 上,你需要安装如 WinPcap(现已弃用)或 Npcap(npcap.com)这样的替代库。如果你在 Windows 机器上使用过其他数据包分析工具,如 WireShark,可能已经安装了其中一个库。

我们将从 network_sim.pcap 文件中读取数据包,该文件可以在本书的 GitHub 仓库中下载。我们的目标是识别网络中那些表现出异常行为的机器,即与正常“预期”行为不同的机器。我们将分析这些数据包,识别数据中的机器,了解它们之间的通信情况,以及发生了什么类型的通信。为此,我们将应用一些网络协议知识,并用大量的统计分析方法来分析数据包图。

构建通信图

捕获文件包含由 Snort 收集点记录的流量(www.netresec.com/?page=ISTS)。该捕获文件包含来自 80 个唯一 媒体访问控制(MAC) 地址的 139,873 个数据包。MAC 地址是由制造商烧录到网络接口卡(NIC)硬件内存中的唯一标识符。简化来说,NIC 的工作是将数据物理传输到网络上的下一个设备(通常是某种类型的路由器或交换机)。如果你使用以太网电缆,NIC 会将电信号沿电缆传输。如果你使用无线 NIC,数据将通过某种接收器和发射器组合广播。当你在家或咖啡店连接到网络时,NIC 会将其 MAC 地址发送给路由器,路由器会根据该 MAC 地址为系统分配一个 IP 地址。如果路由器之前没有见过这个 MAC 地址,它会分配下一个空闲的 IP 地址;但如果该机器之前已分配过 IP 且该 IP 仍然有效,路由器通常会再次分配相同的 IP 地址。然而,有时之前的 IP 地址已被分配给另一个 NIC,因此路由器会为这个曾经见过的 MAC 地址分配一个新的 IP。

我们将使用每个参与数据包传输的设备的源 MAC 地址和目标 MAC 地址作为图中的边标识符。然而,机器在连接之间完全更换网卡的可能性较小,因此 MAC 地址应该保持不变。通过使用 MAC 地址来识别每台机器,我们将能够在不同的 IP 地址之间识别同一网卡,从而构建出一个相对准确的通信图,避免在机器的 IP 地址发生变化时产生混淆。

现在我们知道了可以用来识别系统的数据,我们可以专注于我们感兴趣的网络数据类型。由于有近 140,000 个数据包可用,我们希望进行筛选,以减少数据中的噪音并提高处理效率。此时,你对网络协议的知识将发挥作用。网络流量中可能存在数十种,甚至数百种不同的网络协议。通过理解不同协议及其可能的使用场景,你可以更快速地聚焦于感兴趣的数据。我们没有足够的篇幅深入讲解数据包分析,因此我建议你阅读章节总结中列出的优秀书籍,以了解良好的数据包过滤器在安全分析中的强大作用。

示例文件包括以下协议组成的数据包:

  • TCP:137,837

  • UDP:2,716

  • ICMP:297

  • 其他:1,352

我们的分析将重点关注 TCP 和 UDP 数据包(这两种是常见网络通信中使用的主要数据包类型,例如网页流量)。TCP 和 UDP 是建立在 IP 层之上的,因此我们将忽略没有 IP 层的数据包,从而筛选出除了这两种协议以外的数据包。我们还将提取 IP 地址和端口。端口号在我们讨论通信类型时非常重要,因为许多软件(如数据库和 Web 服务器)往往会使用默认端口号,因此它们在数据包中的存在可以帮助我们猜测通信双方可能使用的系统。通过收集这些 IP 地址信息,我们可以分析哪些 MAC 地址与多个 IP 地址配对。这可以让你了解,仅仅使用 IP 地址作为标识符可能引入多少错误。

构建图

在 Listing 4-1 中,我们将数据包数据加载到一个 MultiDiGraph 中。

import networkx as nx
from scapy.all import rdpcap, IP, TCP, UDP

❶ net_graph = nx.MultiDiGraph()
❷ packets = rdpcap('network_sim.pcap')
❸ for packet in packets:
  ❹ if not packet.haslayer(IP):
        # Not a packet to analyze.
        continue
  ❺ mac_src = packet.src        # Sender MAC
    mac_dst = packet.dst        # Receiver MAC
  ❻ ip_src = packet[IP].src     # Sender IP
    ip_dst = packet[IP].dst     # Receiver IP
  ❼ w = packet[IP].len          # Number of bytes in packet
  ❽ if packet.haslayer(TCP):
        sport=packet[TCP].sport # Sender port
 dport=packet[TCP].dport # Receiver port
  ❾ elif packet.haslayer(UDP):
        sport=packet[UDP].sport # Sender port
        dport=packet[UDP].dport # Receiver port
    else:
        # Not a packet to analyze.
        continue
    # Define an edge in the graph.
  ❿ net_graph.add_edge(
        *(str(mac_src), str(mac_dst)),
        ip_src=ip_src,
        ip_dst=ip_dst,
        sport=sport,
        dport=dport,
        weight=w
    )
print(len(net_graph.nodes))

Listing 4-1:从 pcap 文件填充图

net_graph MultiDiGraph 变量❶将通过 rdpcap ❷ 加载的 pcap 文件进行填充,rdpcap 是 Scapy 的一个函数,能够读取 pcap 文件并返回 Scapy packet 对象列表,这些对象存储在 packets 变量中。为了只筛选 TCP 和 UDP 数据包,我们会遍历每个 packet 对象❸,并检查它是否定义了 IP 层❹。如果定义了,我们将从基础数据包中提取源和目标 MAC 地址,分别使用 packet.srcpacket.dst ❺,这会给我们一些边缘属性。

Scapy packet对象以层的方式存储每个封装在数据包中的协议属性,像 MAC 地址这样的以太网卡数据存储在基础层中。我们通过类似字典的索引访问额外的层级:例如,IP层中的源 IP 地址和目标 IP 地址分别在packet[IP].srcpacket[IP].dst ❻中。我们提取这些信息作为边属性。为了根据每个数据包发送的字节数来加权边,我们将packet[IP].len属性 ❼保存在w中,并稍后将其存储在边的weight属性中。使用weight作为属性名称,NetworkX 将自动识别并在分析中使用它。通过数据包的 IP 层长度来加权每个边是估算机器间传输数据量的简单方法。

最后,我们检查数据包是否有TCP ❽或UDP层 ❾。我们需要进行这个额外的检查,因为并非所有带有 IP 层的数据包都是来自 TCP 或 UDP 协议。例如,互联网控制消息协议(ICMP)数据包也有 IP 层信息,但它们的格式与 TCP 或 UDP 数据包不同。

如果数据包中存在 TCP 或 UDP 层,我们提取源端口和目标端口;否则,我们跳过该数据包。我们使用收集到的属性作为边属性,并将 MAC 地址作为节点 ID ❿,为每个符合条件的数据包创建一个边。最后,我们可以打印出net_graph对象的长度,这将告诉我们创建了 80 个节点。图 4-2 展示了网络数据的三维图形表示。

图 4-2:网络的三维表示

我已经使用nx.random_layout函数生成了这个图的坐标值,作为目前的占位符,因为我们还没有定义要寻找的内容。该函数默认只生成* x y *值,但你可以传递参数dim=3,让它生成三维坐标。我们将进行其余的分析时使用 2D,但我想展示大多数人思考的图的样子——三维的。能够像这样轻松地以三维形式展示复杂的网络,节省了大量时间。即使这个图看起来很复杂,你已经能从中感知到通信中的重要节点。例如,位于上方中央区域的一个节点,有很多边连接到图中其他节点。不过,除了这些非常基础的观察外,你不会获得太多的深刻见解。

节点数量和复杂的交互使得自动化图分析方法非常适用。利用我们已经掌握的理论,我们将把这个图解开,变成有组织且富有信息的子图。你将运用新学到的边过滤和求和技巧,发现哪些节点在使用有趣的协议(如 HTTP 和 HTTPS)进行通信。我们将检查哪些机器通过出度连接的度量与大量其他机器进行联系,最后,我们将探索一个概念验证程序,允许你捕获并分析自己网络中的数据包。

识别可疑的机器行为

让我们在网络数据的背景下重新审视“接近度”这一概念。既然我们记录了数据包的目标端口,并将边的权重定义为相应数据包中传输的字节数,那么我们对网络提出的第一个自然问题是:“哪些机器在使用哪些协议进行通信?”如果我们假设某些目标端口的流量属于某种协议或应用(例如,80 端口用于 HTTP,或 22 端口用于 SSH),那么这个任务等同于问:“哪个节点向给定端口发送的数据量最多(按数据包字节数计算)?”我们的简化假设实际上是网络工具(如 Nmap)中快速协议指纹识别的基础,所以我在这里做出这个假设是很有根据的。我们可以更正式地将协议使用的问题重新表述为:

给定一组协议 Ψ,确定哪个节点具有协议 Ψ[(][i][)]的最高加权出度。

实际上,调查人员在检查网络操作时,常常会问这样一个问题:如何识别表现异常的机器(即偏离观察到的平均值的机器),因此将这一过程自动化是有意义的。

端口数据量的子图

你可以通过首先创建一个仅包含类型为 Ψ[(][i][)](例如 SSH)边的子图,然后测量子图中每个节点的加权出度,简单快速地调查给定端口的数据量。清单 4-2 为清单 4-1 中的代码添加了一个辅助函数,用于为任意端口号创建子图。

def protocol_subgraph(G, port):
    o_edges = [(u,v,d) for u,v,d in G.edges(data=True) if d["dport"] == port]
    if len(o_edges) < 1:
        return None
    subgraph = nx.DiGraph()
    subgraph.add_edges_from(o_edges)
    return subgraph

清单 4-2:一个协议子图辅助函数

函数protocol_subgraph接受图和端口号作为参数,收集所有表示流量该端口的边,并创建一个简单的有向图。使用条件语句if d["dport"] == port的列表推导式将边集修剪为仅包含感兴趣的边。然后,它创建一个DiGraph对象,并通过nx.add_edges_from将修剪后的边集添加到图中。正如我之前提到的,这也会将节点添加到图中。因为 NetworkX 会自动对DiGraph中相同两个节点之间的多条边的weight属性进行求和,所以subgraph中每条边的weight属性将表示两台设备之间所有数据包的字节总数。

然后,我们可以使用nx.out_degree函数检查返回的子图中每个节点的外向流量。清单 4-3 展示了如何获取端口 80 的相关信息。

dG = protocol_subgraph(net_graph, 80)
out_deg = dG.out_degree(weight='weight')
sorted_deg = sorted(out_deg, key=lambda x: (x[1], x[0]), reverse=True)
print(sorted_deg[0])

清单 4-3:查找具有最多外向流量的单协议机器

首先,我们调用清单 4-2 中定义的辅助函数,传入在清单 4-1 中创建的MultiDiGraph和感兴趣的端口(在此示例中为80),然后我们调用out_degree函数,该函数返回子图中每个节点的外部边的原始计数。为了将行为更改为返回求和后的边权重,我们显式传递out_degreeweight参数。通常,NetworkX 会自动识别weight参数,但在我测试代码时,出于某些原因,它并没有识别。添加显式引用weight属性解决了该问题。

为了找到在端口 80 上发送最多数据的设备,我们使用sorted函数对结果进行排序。key参数传入一个用于排序复杂对象(如元组或字典)的函数。我们传入一个 lambda 函数,它接受一个形如(节点 ID,外度权重)的元组,并按(外度权重,节点 ID)的顺序对项进行排序,因此节点首先按外度排序;如果存在平局,节点 ID 会作为决胜负的标准。reverse选项将项按降序排序(默认是升序)。排序后的列表中的第一个项现在具有最高的外度,正如你在代码输出中应该看到的那样:

('1c:6a:7a:0e:e0:41', 592)

由于我们的目标是识别有趣或异常的网络活动,例如在 SSH 和 HTTP 等关键服务上的网络外向活动突然增加,我们希望列出协议,并确定每个协议中具有最高加权外度的节点。这相当于

该方法定义了一个 |V| × j 矩阵(也称为二维数组,适合代码员),其中 j 是要检查的协议数量。该条目 m04002 存储了协议 j 对于节点 u 的边的总权重。

在清单 4-4 中,我们再次利用了清单 4-2 中的protocol_subgraph函数来回答“哪些机器具有最高的加权通信?”这个问题,涵盖了四个常见的端口:HTTP、数字专用网络信令系统(DPNSS)、Metasploit RPC 守护进程的默认端口(Armitage 团队服务器也使用该端口)以及 HTTPS。

psi = [80, 2503, 55553, 443]
for proto in psi:
    dG = protocol_subgraph(net_graph, proto)
    out_deg = dG.out_degree(weight='weight')
    sorted_deg = sorted(out_deg, key=lambda x: (x[1], x[0]), reverse=True)
    print(proto, sorted_deg[0])

清单 4-4:定位具有最高外向流量的多协议机器

对于psi中的每个端口号,我们创建一个协议子图dG;然后,对于子图中的每个节点,我们计算所有外度边的权重总和。一旦计算出当前协议中所有节点的权重,我们按权重升序对得分进行排序,并打印出每个结果集中的第一个项。

这是该函数的输出:

80 ('1c:6a:7a:0e:e0:41', 592)
2503 ('00:26:9e:3d:00:2a', 949)
55553 ('1c:6a:7a:0e:e0:41', 52)
443 ('00:0c:29:ac:42:4b', 678)

每一行输出都给出了端口号、节点地址以及每个协议中流量最多的节点的出度得分。作为安全研究人员,第一个应当引起你注意的是端口 80 和端口 55553 的节点是相同的。这一点很有趣,因为端口 55553 被之前提到的渗透测试软件使用,而端口 80 通常代表未加密的网页流量。这可能表示某种扫描器,在探测未加密的网页内容并将数据报告回 Metasploit 服务器。如果我在调查该网络中的可疑用户,我会开始深入挖掘1c:6a:7a:0e:e0:41的行为。

另一个值得关注的项目是端口 2503 上的 DPNSS 流量可能表明存在私人分支交换机(PBX),即组织内部使用的私人电话网络。00:26:9e:3d:00:2a很可能是某种 IP 语音(VoIP)电话,但你需要进一步调查以确认这一假设。VoIP 是一种有趣的协议,因为如果安全措施不当,攻击者可以窃听通话、注入音频到电话会议中、重新路由或阻止通话,甚至以其他方式干扰连接的电话系统。

识别异常流量水平

为了找出哪个节点接收了某个协议最多的数据,我们可以使用protocol_subgraph函数中的接收端口,并改为衡量入度。问题是,如何确定接收的流量量是否正常或可疑。为此,我们通过将每条边的权重求和并除以协议子图中的边数,来估算网络上入站流量的平均值:

如果我们假设某协议的流量是正态分布的(意味着大多数系统在特定协议下接收的流量是相似的),我们可以使用z-score公式,将检测到的使用量与平均值进行比较,根据其与平均入站流量ϖ的差异是否由正常变异引起,给节点打分。我们可以选择我们希望多有信心(通常在 80 到 99.9%之间),即变异不是偶然的。更高的置信水平意味着更多的变异会被视为“正常”,而更少的数据会被标记为异常,或者更简单地说,观察值与期望值之间的差异必须多大,才能被认为是“奇怪的行为”。列表 4-5 展示了如何为 HTTP 协议子图实现这一点。

from scipy import stats
import numpy as np
protoG = protocol_subgraph(net_graph, 80)
in_deg = list(protoG.in_degree(weight='weight'))
scores = np.array([v[1] for v in in_deg])
❶ z_thresh = stats.norm.ppf(0.95) # 95% confidence
in_degree_z = stats.zscore(scores)
❷ outlier_idx = list(np.where(in_degree_z > z_thresh)[0])
nodes = [in_deg[i][0] for i in outlier_idx]
print(nodes)

列表 4-5:使用 z-score 识别异常值

我们首先从 SciPy 库中导入stats模块,并将 NumPy 库导入为np。接下来,通过将源图和 HTTP 端口 80 传递给我们在示例 4-2 中定义的protocol_subgraph函数,来定义协议子图protoG。然后,我们使用protoG.in_degree函数计算加权入度。我们使用一个名为scores的 NumPy 数组来存储加权入度得分。接下来,我们根据选择的置信水平查找z阈值;在这个例子中,我们选择 95%的置信水平,相关的z阈值是 1.645 ❶。这个值表示我们用来区分正常数据和异常数据的标准差数。

使用这一组数据,我们通过stats.zscore函数计算协议子图中每个节点的 z-score,并将其保存到in_degree_z中。z-score 的值以 0 为中心,因此负值代表入度加权低于平均值的节点。由于目前我们不关注流量低于平均水平的系统,所以我们只保留那些大于我们通过np.where(in_degree_z > z_thresh)设置的阈值的得分,并将这些得分称为离群值。

结果是一个包含单个元素的嵌套列表,因此我们取第 0 个元素,它是一个包含scores数组中高于阈值的值索引的 NumPy 数组 ❷。我们将其保存到一个名为outlier_idx的列表中。最后,我们通过查找outlier_idx中的每个元素在in_deg中的对应项,将索引转换为节点 ID。

我们运行代码并发现两个有趣的节点 ID,我们有 95%的把握,它们在端口 80 上的流量明显高于其他节点:

['7c:ad:74:c2:a9:a2', '1c:6a:7a:0e:e0:4e']

图 4-3 展示了端口 80 的协议子图,使用的是入度度量。

图 4-3:网络上 HTTP 流量的协议子图

图中的每个节点都在端口 80 上发送或接收了数据包。黑色菱形节点表示示例 4-5 返回的感兴趣节点。带标签的节点具有最高的入度:在端口 80 上有三个不同的入站连接。圆形灰色节点的入度在端口 80 流量的正常范围内。

从安全角度来看,这两个问题(哪个系统发送了最多某种类型的流量,以及哪些系统接收了统计学上显著数量的某种类型的流量)使我们能够评估网络中节点的行为。通过在一段时间(例如两周)内测量正常、无入侵的流量,然后与实时捕获的流量进行比较,您可以找到行为变化(无论是流量量变化还是执行的操作),这些变化可能表明发生了安全泄露。例如,如果您知道00:26:9e:3d:00:2a绝对不是一个 VoIP 电话,那么突然发出的连接到电话网络的行为可能会引起警觉。至少,您应该联系该机器的操作员,以了解为什么这种行为发生了变化。

检查机器在网络上的交互

作为一名安全分析师,您可能有兴趣了解机器如何在网络中以不同但相关的方式进行交互。您可能会提出一些与协议无关的问题,例如“哪个机器联系了最多的其他机器?”或者“哪个机器吸收的信息最多?”在我的网络中,通常不同机器之间几乎没有互相通信(例如,我的 3D 打印机不应该与我的监控摄像头控制器通信),唯一的例外是我的节点,它会定期连接到所有这些机器。通过检查我的网络中的邻居,您很快就会发现我在运行哪个节点。而且您可能已经猜到,由于它隐性地倾向于那些是许多不互联节点的邻居的机器,我在介入中心性上的得分也会很高。

测量系统之间交换的信息量是识别试图从网络中窃取数据的机器及其窃取来源的另一种方法。在信息交换分析中,您可能会定位到作为信息存储库的机器(例如文件服务器和数据库),这些机器通常接收的信息比发送的多。另一类则是数据流服务器,它们产生的数据远多于接收的数据。为了开始这一分析,我们首先将问题更正式地表述,然后开发代码来进行调查。

确定请求节点

第一个问题可以更正式地表述为:

对于G中的所有节点,找到具有最多外向邻居的节点。

我称这个节点为“请求节点”,因为它的行为像一个在社区里逐门逐户推销产品或收集签名的人。网络扫描器(如 Nmap)会对它在网络上找到的任何机器创建外向连接,这使得运行这些工具的机器在我们的分析中会显得特别突出。我们可以通过将两个节点之间的多个边缘汇总为单一边缘,然后计算每个节点的外度来找到我们问题的答案,如 Listing 4-6 所示。

dG = nx.DiGraph()
dG.add_edges_from(net_graph.edges(data=True))
out_deg = dG.out_degree()
out_deg = sorted(out_deg, key=lambda x: (x[1], x[0]), reverse=True)
u, score = out_deg[0]
print(u, score)

Listing 4-6:查找具有最多外部连接的机器

我们将MultiDiGraph net_graph中的所有边添加到一个新的有向图dG中,这样 NetworkX 就可以将节点之间的多个边总结为一条具有合并weight属性的单一边。然后,我们使用总结后的图中的出度来找到具有最大值的节点,通过将列表按降序排序并选择第一个节点。正如我之前提到的,平局节点将根据节点 ID 的字母顺序进行排序。我们从分组中创建一个网络图,并通过节点 ID 的字典顺序进行排序。

Listing 4-6 中的代码将识别出节点1c:6a:7a:0e:e0:44作为具有最多外部连接的节点,它与网络中的 13 个其他节点相连。Jupyter 笔记本中的代码Chapter 4 - Packet Analysis with Graphs.ipynb(在补充材料中)将把这些机器收集成一个子图,类似于 Figure 4-4 所示的样子。

Figure 4-4:具有最多外部连接的节点的图示

你可以看到左侧显示了生成流量的节点,带有向外的箭头,并且它与 13 个通信过的系统在所谓的外壳布局中展开(因为它看起来像一个海贝壳)。

你通常会在识别出一个可疑机器之后执行这样的分析作为后续步骤。通过检查该机器联系过的系统类型,你可以更深入了解潜在攻击者的技能、动机和工具。如果继续进行此分析,下一步将是收集与每条边相关的分组,并使用你喜欢的分组分析方法进行分析。

确定吸收最多数据的节点

接下来,我们要找出吸收(接收的数据多于发送的数据)最多数据的机器,也就是信息交换比率最大的节点。信息交换比率(IER)可以数学地表示为给定节点的入度权重与出度权重的比率:

直观地说,一个每接收三字节数据、发送一字节数据的机器,其比率大致为 3:1。一个生成的分组比消耗的更多的机器,其比率则是倒数,即 1:3(每接收一个字节,发送三个字节)。这个公式通过在分子和分母上加 1 来避免计算中的 0。NetworkX 并没有提供一个方便的函数来计算信息交换比率,因此我们在 Listing 4-7 中创建了一个函数,用于计算每个节点的比率。

def exchange_ratios(G):
    res = []
  ❶ for u in G.nodes.keys():
      ❷ out_edges = G.out_edges(u, data=True)
        in_edges = G.in_edges(u, data=True)
        if len(out_edges) > 0:
          ❸ out_w = 1 + sum([d["weight"] for u,v,d in out_edges])
        else:
          ❹ out_w = 1
        if len(in_edges) > 0:
            in_w = 1 + sum([d["weight"] for u,v,d in in_edges])
        else:
            in_w = 1
      ❺ ier = in_w / out_w
        res.append((u, ier))
    return sorted(res, key=lambda x:(x[1], x[0]))

Listing 4-7:用于计算所有 IER 的函数

我们首先遍历输入图中的所有节点 ID ❶,并为当前节点调用graph.out_edgesgraph.in_edges函数 ❷。对于具有大于零的出度边数量的节点,我们使用列表推导式收集权重,并立即将这个权重列表传递给sum函数,再加上 1 以得到总和 ❸。对于出度为0的节点,我们赋予其一个基础值1 ❹。(没有入边和出边的节点将得到 1 / 1 = 1 的值。)一个只有一个入边且没有出边的节点将得到 2 / 1 的分数,依此类推。我们对入边进行相同的处理,然后将两个权重总和相除,得到当前节点的 IER ❺。最后,我们返回一个元组列表,按比例值升序排序。如果要按降序排序,只需在调用sorted时使用参数reverse=True

我们可以调用exchange_ratios函数,如清单 4-8 所示。

❶ ier_scores = exchange_ratios(net_graph)
❷ z_thresh = round(stats.norm.ppf(0.99),3)
❸ ier_z = stats.zscore([s[1] for s in ier_scores])
❹ outlier_idx = list(np.where(ier_z > z_thresh)[0])
❺ ier_outliers = [ier_scores[i] for i in outlier_idx]
print(ier_outliers)

清单 4-8:查找具有最高信息吸收比的节点

这段代码与清单 4-5 非常相似,在那里我们测量了每个节点的入度 z 分数。我们首先调用之前创建的net_graph对象上的exchange_ratios函数,并将返回的元组列表存储到ier_scores变量 ❶。接着,我们定义我们用于 z 分数测试的置信度阈值 ❷。99% 的置信度(四舍五入到小数点后三位)将认为值超过均值 2.326 个标准差的数据是异常值。为了生成 z 分数列表,我们传入一个仅包含ier_scores列表中每个元组的分数元素的列表 ❸。我们使用np.where函数查找 IER 值的 z 分数大于我们定义的阈值的位置索引 ❹。最后,我们使用返回的索引在ier_scores列表中查找相应的节点和 IER 分数 ❺。当我运行这段代码时,得到如下输出:

[('01:00:5e:7f:ff:fa',18570.0),('ff:ff:ff:ff:ff:ff',35405.0),('01:00:5e:00:00:fb',46026.0)]

这三个节点的 IER 分数,我们可以 99% 确定它们超出了该网络正常变异范围。我们可以放心地忽略ff:ff:ff:ff:ff:ff结果,它是网络的广播地址。程序可以将数据包发送到此地址,以告知网络将数据包广播到网络中的所有机器。我们预计广播地址会有最高的 IER,因为它不应该生成任何自身的流量。我们发现,吸收比最高的节点是01:00:5e:00:00:fb,每生成一个字节就吸收了 46,026.0 字节。需要注意的是,领先节点的分数 46,026.0 是下一个最高异常值01:00:5e:7f:ff:fa的两倍多(忽略广播地址)。

吸收大量网络数据的节点在多个安全方面都很有趣。首先,一个比平均值更高的 IER 的节点可能正在下载大量文件;这样的下载通常从发送一到两个数据包请求文件开始,然后接收大量包含实际文件数据的数据包。下载大量数据本身不一定是危险的,但它可能表明有人在爬取网络寻找敏感信息。它也可能是发生泄露后试图窃取数据的表现。因此,值得调查 IER 变化的原因。

我希望到现在为止,我已经激起了你对网络图中隐藏结构的好奇心。这是识别可疑机器的一个很好的起点,但仍然有很多内容需要你自己去探索。例如,前面我们识别出了可疑机器1c:6a:7a:0e:e0:41。你能从数据中判断出这台机器有多少个不同的 IP 地址吗?也许构建一个子图,展示它们随时间的通信情况,会让你更深入地了解它们的行为。

你也可以尝试应用我们在第三章讨论的团体分析技术,看看能否找到任何通信簇。首先,问问自己:“什么样的网络场景会在计算机之间创建一个团体?”然后查看网络中是否有任何团体支持或反驳这个假设。这种猜测-再测试的模式是所有应用科学的核心。我自己并没有在数据中进行这方面的调查,所以你可能会找到一些你自己独特的、有趣的见解。获得这些自发的见解,才是真正应用数学到安全话题中的力量和奖励。

概念验证:网络流量分析

为了继续将图论应用于网络流量分析,你可以下载公开的 pcap 文件,或者从你周围的网络捕获数据包(当然,需要获得许可!),然后从捕获的文件构建图并尽情分析。许多研究人员面临的困难不在于应用分析,而在于首先构建图。你已经在示例 4-1 中看到了一个例子,因此你已经走在了前面!因此,我将在本章结束时提供一个证明,展示如何实际弥合野外数据和漂亮的可分析图之间的差距。这个概念验证从之前捕获的文件或从网络接口读取的实时数据生成图,然后将图保存为边列表文件,你可以将其加载到其他分析脚本中。我鼓励你下载这个概念验证,进行实验,然后将其整合到更大的工具中,特定于你的应用程序。

你将需要packet_analysis/packet_analysis.py文件,该文件包含定义命令行接口(CLI)的代码,以及packet_analysis/graph_funcs.py文件,该文件包含我们目前讨论的函数和一些其他有用的函数。pcap_graph函数定义了一个函数包装器,用于清单 4-1 中的代码,它允许你传入一个数据包列表进行处理。save_packet函数是一个便捷函数,用于使用 Scapy 的wrpcapwrite pcap的缩写)函数将捕获的数据包数据附加到指定的 pcap 文件中。一旦文件被写入,你可以使用file_to_graph函数通过 Scapy 的rdpcapread pcap的缩写)函数加载捕获的数据。然后,你将使用pcap_graph函数将数据包数据转换为MultiDiGraph对象进行分析,如下代码所示:

def file_to_graph(pcap_file):
    packets = rdpcap(pcap_file)
    new_graph = pcap_graph(packets)
    return new_graph

一旦图形对象创建完成,你可以使用save_graph函数(这是 NetworkX 的write_weighted_edgelist函数的封装器)将加权边表示写入文件。将数据包捕获存储为边列表可以减少图形的加载时间。与其在每次分析运行时将数据包转换为边,不如一次性创建基础图并在未来的分析中加载它(而不是 pcap 数据)。这种工作流程被称为写一次,读多次(或WORM,源自同名的数据存储术语)。

每当我进行概念验证时,我都会放弃华丽的用户界面,通常会在我想测试的代码周围包装一个命令行界面(CLI)。保持简单可以让你专注于核心概念,而不会被显示问题或无关的交互问题分散注意力。本章的概念验证使用了 optparse 库来创建一组数据包捕获选项,你可以用来配置捕获多少数据包、你希望从哪里捕获它们,等等。首先,打开命令行控制台,导航到packet_analysis/目录,并运行

$ **python packet_analysis.py -h**

这应该会显示用于运行概念验证的可用选项,如清单 4-9 所示。

Usage: packet_analysis.py [options]
Options:
  -h, --help show this help message and exit
  -i IFACE, --iface=IFACE The network interface to bind to       (required, -i all for all)
  -c COUNT, --count=COUNT Number of packets to capture                         (default 10)
  -r RAW_FILE, --raw-out=RAW_FILE File to save the captured packets to       (default None)
  -s GRAPH_FILE, --graph-out=GRAPH_FILE File to save the created graph to        (required)
  -l LOAD_FILE, --load=LOAD_FILE Pcap file to load packets from

清单 4-9:概念验证运行选项

如你所见,-h选项对应帮助。默认情况下,optparse 包含此选项,并会打印出你为每个选项定义的帮助消息。如果你放下一个项目一段时间后再回来,这些帮助消息会是一个很好的提醒。其余的选项和实现它们的逻辑存储在packet_analysis.py文件中。

要从所有网络接口捕获一定数量的数据包,然后将它们保存为图形表示,可以使用类似下面的命令:

$ **python packet_analysis.py -i all -c 100 -s my_test.edges**

-i--iface)选项接受一个接口名称作为字符串。在特殊情况下,字符串all表示 Scapy 会尝试绑定到所有可用的网络接口。-c--count)选项定义在退出嗅探器之前要捕获的数据包数量。这个选项在你的程序中并非严格必要,但在原型设计时,它有助于保持文件大小可管理。最后,-s--graph-out)选项指定你希望输出加权边列表文件的位置,该文件是在调用nx.write_weighted_edgelist函数时生成的。一旦你将数据包捕获图保存为加权边列表,你可以通过使用nx.read_weighted_edgelist将其重新加载到图中,供自己的分析脚本使用:

G = nx.read_weighted_edgelist("my_test.edges", create_using=nx.DiGraph)

默认情况下,NetworkX 会从边列表创建一个无向图。要创建一个有向图,你需要在create_using参数中传入nx.DiGraph

你还可以使用概念验证来从现有的 pcap 文件创建加权边列表文件,这对于事后分析非常有用。要将net_sim.pcap文件转换为加权边列表文件,你可以将-l--load)参数与-s参数结合使用,如下所示:

$ **python packet_analysis.py -l network_sim.pcap -s sim_test.edges**

概念验证还支持从特定接口捕获数据包并将其保存到 pcap 和边列表文件中。通过同时执行这两个操作,你可以保留更多信息。你可以直接从边列表开始创建未来的图,而无需先转换 pcap 文件,但仍然可以将 pcap 数据发送到其他工具。以下命令展示了如何将-r--raw-out)参数与其他参数结合使用,以便同时创建 pcap 文件和图。这在从实时流量捕获时最为有用(否则你已经有了 pcap 文件)。要捕获流量,脚本需要权限将网卡设置为混杂模式,这是大多数系统上的受限功能,因此你需要在 Linux 和 macOS 上以 root 用户身份运行以下命令,或者在 Windows 上以管理员帐户运行。(如果你在 Anaconda 中运行设置,你需要使用特权帐户创建虚拟环境,以便能够以正确的权限运行脚本。)

$ **python packet_analysis.py -i** `eth0` **-c 100 -s my_test.edges -r cap_test.pcap**

在运行此命令时,确保将eth0更改为你机器上的接口。如果你使用的是 Windows 系统,可能很难找到正确的设备名称。如果你只使用一个网络接口,最简单的方法是使用-i all选项。结果将是一个包含所有原始数据包信息的附加文件。

这种方法占用的存储空间是所有选项中最多的。所需的存储量取决于捕获的包数量以及为每个边存储的信息量。在进行大规模捕获(例如,超过 2000 个数据包)时,务必监控机器的存储容量。你可以使用-c标志来设置捕获的数据包数量。或者,你可以将结果文件发送到云存储位置,并可能将捕获数据汇总以进行真正的大数据分析。我们将在第十三章中进一步讨论云部署。

总结

本章为你构建未来的网络分析工具提供了一个良好的起点。你现在应该能够轻松地将网络加载为图形,使用一些统计分析找到有趣的节点,并重新组织数据以适应你的问题。你已经看到如何在概念验证代码中捕获源数据的实际例子。现在是时候展开你自己的研究了。你可以向边属性中添加更多信息(例如,创建时间),将其扩展到处理其他协议层(ICMP 是一个不错的起点),并进行许多其他有用的改进。一旦你熟悉了如何将数据包数据转换为可度量的图形,并使用 NetworkX 进行操作,你可以参考有关计算机网络结构的研究,这些研究丰富且通过搜索引擎易于访问,从而扩展你的分析。

例如,如果你有兴趣将图论应用于理解云计算中的资源使用,可以查阅 Kanniga Devi Rangaswamy 和 Murugaboopathi Gurusamy 的研究论文《图论概念在计算机网络中的应用及其在云计算资源供应问题中的适用性——综述》^(1),该论文还包含了与图论相关的资源列表以及所涉及理论的描述。我认为,仅这一部分就使得该论文成为必读之作。

正如你在本项目的第一章中看到的那样,将理论转化为实践的关键在于提出明确的问题。例如,协议使用问题使我们能够识别网络中的潜在威胁。像《实用数据包分析》(2)(克里斯·桑德斯著)和*《攻击网络协议》*(3)(詹姆斯·福肖著)这样的书籍可以为你提供更具体的网络剖析知识,帮助你提出更好的数据问题。在阅读这些书籍时,思考你所学习的工具和技术如何依赖于我们在此应用的原则。或许你会找到一种独特的方式,来分析一个你感兴趣的网络协议。

在下一章中,我们将告别这个数字秩序的世界,进入一个定义不那么明确的社交网络世界,并提出问题,这些问题将彻底重新定义我们对图的理解。

第七章:使用社交网络分析识别威胁

社交网络分析 (SNA) 是图论的一个子集,它用数学方式描述复杂的人类互动;它可以应用于任何涉及人类互动的研究领域。安全研究人员使用 SNA 进行从预测恶意内容传播到识别潜在内部威胁的各种工作。在本章中,我们将进行自己的 SNA:我们将从 Mastodon 的帖子构建一个简单的社交网络图,然后利用它来理解用户之间的影响力和信息交换。具体来说,我们将研究一个真实的社交网络效应,称为小世界现象。接下来,我们将看看如何从帖子中构建图形,回答几个研究问题,最后进行一个概念验证项目,在该项目中,你将能够从你自己的 Mastodon 时间线收集数据。

小世界现象

斯坦利·米尔格拉姆的小世界实验展示了群体从众对人类决策的影响。实验的目的是研究美国社交网络的平均路径长度,其中路径长度是指从网络中的一个人到另一个看似不相关的人的信件传递所需的人员数量。米尔格拉姆通常选择美国内布拉斯加州奥马哈和堪萨斯州威奇托的个人作为起点;马萨诸塞州波士顿的人通常作为终点。在收到参与邀请后,被指定为原始信件发送者(起点)的人需要被询问是否个人认识随机选中的最终接收人(终点)。如果认识,起点需要直接将信件转交给终点。在更常见的情况下——起点不认识终点——起点会被要求想到一个更可能认识终点的朋友或亲戚。路径上的人们可以将信件转交给他们认识的任何人,以便将信件送得更接近终点。

从技术上讲,如果社交网络中任意两个人之间通过少数中介熟人很可能被连接,那么该社交网络就展现了小世界现象。Milgram 的研究表明,我们的社会是一个强连接的网络:任何两个网络成员之间,很可能通过三到六个中介熟人相连(这通常被称为 六度分隔,即小世界现象的一个特定案例)。在小世界现象中起作用的机制被称为 优先连接,即一个人更可能与已经有许多连接的人建立联系。简而言之,统计上你更可能遇到一个去结识许多新人的人,而不是遇到一个几乎没有社交互动的隐士。事实证明,这种类型的网络在自然界中广泛存在,从动物社会结构到人脑中都有观察到。显然,作为安全分析师,理解这一点非常值得我们花时间去研究。

我们分析虚拟帖子和用户数据集的目标是回答以下研究问题:

  • 有多少信息会被传播?

  • 这个网络中存在哪些社交圈?

  • 谁是三位最具影响力的用户?

  • 谁是三位最受影响的用户?

  • 谁能引入最多的新连接?

在本章的其余部分,我们将依次讨论每个主题,看看如何重新解读之前的理论以深入理解社交网络用户交互,并探索新的图论主题,如残余信息和节点谱系。

绘制社交网络数据

为了将我们的社交网络数据转化为图形,首先我们需要将其结构化为可搜索的表格格式。我们将使用 pandas 库,它为我们提供了帮助组织数据并为图形化做准备的函数和数据结构。

首先,让我们看看原始的 JSON 数据。文件 fake_posts.json,随书的补充材料提供,包含了 28,034 个类似帖子的对象,格式遵循列表 5-1 中展示的 JSON 架构。

{
  ❶ "id": 4912964953915055,
    "created_at": "2019-5-22 23:03:22",
  ❷ "content": "Process within summer especially song when letter nearly.",
    "source": "Data Faker",
  ❸ "in_reply_to_screen_name": "Some User",
    "in_reply_to_id": 1234334523168,
    "in_reply_to_account_id": 346835683,
  ❹ "account": {
        "id": "6336091949992",
        "screen_name": "juliekennedy",
        "location": "846 Adam Spring #616\nE Chicago, IL 21342",
        "description": "Faked profile Data",
        "url": "http://www.smith.com/"
    },
  ❺ "reblog_count": 0,
    "liked_count": 0
}

列表 5-1:使用 Mastodon 架构示例的模拟 API 响应

id 字段 ❶ 包含一个由 API 在创建帖子时分配给单个记录——即帖子——的数字 ID。content 字段 ❷ 包含添加到网络中的数据——也就是构成帖子的文本。一些帖子是原创的,其余的是对帖子、或对回复的回复,依此类推。reblog_count 字段 ❺ 统计帖子对象收到多少次回复。表示转发的对象将包含以 in_reply_to_ 开头的字段名称 ❸。account 字段 ❹ 是一个嵌套的 JSON 对象,用于标识帖子的创建者。

数据结构化

我们将从帖子对象中保留比 第四章 中的包对象更多的数据,并且帖子对象结构是嵌套的,所以首先我们需要将数据文件加载到 pandas DataFrame 对象中。DataFrame 对象是 pandas 中用于存储表格数据的行和列数据结构。它在结构上类似于数据库(甚至支持一些相同的操作,如数据过滤和连接)。使用 DataFrame 可以为我们提供更方便的语法来排序和选择相关的帖子对象,它也展示了将分析库结合使用的强大功能。通过结合使用工具(在这种情况下是 pandas 和 NetworkX),你可以为特定任务选择合适的工具,而不是强迫某个库做它本来不擅长的事。

列表 5-2 中的代码定义了一个来自示例数据的 DataFrame 对象。

❶ import pandas as pd
import json

❷ def user_to_series(dict_obj):
    """Convert a nested JSON user into a flat series"""
    renamed = {}
    for k in dict_obj.keys():
        nk = "user_%s" % k
        v = dict_obj[k]
        renamed[nk] = v
    ret = pd.Series(renamed)
    return ret

series_data = [] # 1 JSON object per post object
❸ with open("fake_posts.json") as data:
    text = data.read().strip()
    rows = text.split("\n") # JSON objects stored as list of strings
for row in rows:
  ❹ obj = json.loads(row)   # Converted row string to JSON object
    series_data.append(obj) # Add to JSON list

❺ t_df = pd.DataFrame(series_data) # 1 row per JSON object
❻ post_df = pd.concat([t_df, t_df["account"].apply(user_to_series)], axis=1)
# Data is flat now. Remove the original JSON object feature.
❼ post_df.drop("account", axis=1, inplace=True)

列表 5-2:从示例 JSON 数据创建 pandas DataFrame 对象

在导入所需的库❶之后,我们定义了一个名为 user_to_series 的辅助函数❷,稍后我会详细讲解这个函数;从高层次上看,这个函数将每个 JSON 用户对象转换为适合在 pandas DataFrame 中使用的行。我们使用常规方式通过 with open ❸ 加载 fake_posts.json 文件,使用 strip 函数去除任何尾随的空白字符,并通过剩余的 "\n" 字符将文件数据分割为行。pandas 库可以从 JSON 对象列表中创建 DataFrame,因此我们使用 json.loads ❹ 将每一行字符串转换为 JSON 对象,并将这些对象收集到 series_data 列表中❺。

不幸的是,当 JSON 包含嵌套对象时,比如 account 字段,pandas 并不知道如何解包它们。我们需要使用 pandas 的 applyconcat ❻ 函数将嵌套字段转换为扁平化的 pandas 对象,通过对数据中的每一行应用 user_to_series 函数,生成一个扁平的 pandas Series。你可以将 series 理解为类似于数据库术语中的一行 —— 它将所有与单个条目相关的数据组织在一起。

pd.concat pandas 函数将这些新特性附加到当前的 DataFrame,并应用于所有行。axis=1 参数告诉 pandas 使用序列作为 特征(在数据库术语中是列),这使得 DataFrame 中每一列与用户字段中的每一项数据相匹配(如用户名和 ID)。每一行代表一个用户,每一列则保存该用户对应字段的值。

最后,我们通过 DataFrame.drop ❼ 删除不再需要的原始 account 特性。加载完初始数据集并应用了所有列处理后,我们可以通过调用 post_df.info 打印出数据的结构。列表 5-3 展示了从 列表 5-2 得到的结构,你可以在补充材料中的 Jupyter notebook Mastodon_network.ipynb 中看到它。

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 28034 entries, 0 to 28033
Data columns (total 14 columns):
 #   Column                   Non-Null Count  Dtype  
---  ------                   --------------  -----  
 0   id                       28034 non-null  int64  
 1   created_at               28034 non-null  object 
 2   source                   28034 non-null  object 
 3   content                  28034 non-null  object 
 4   in_reply_to_account_id   10302 non-null  object 
 5   in_reply_to_id           10302 non-null  float64
 6   reblogs_count            28034 non-null  int64  
 7   favourites_count         28034 non-null  int64  
 8   user_id                  28034 non-null  object 
 9   user_screen_name         28034 non-null  object 
 10  user_location            28034 non-null  object 
 11  user_description         28034 non-null  object 
 12  user_url                 28034 non-null  object 
 13  in_reply_to_screen_name  10302 non-null  object 
dtypes: float64(1), int64(3), object(10)
memory usage: 3.0+ MB

列表 5-3:pandas 中的帖子数据结构

数据结构告诉我们一些重要的信息。首先,RangeIndex属性告诉我们当前DataFrame对象中有多少行数据。在这个例子中,我们已经加载了 28,034 条帖子记录,索引从028033。接下来,我们可以看到列表中已经没有account列,这意味着我们在 Listing 5-2 中的删除操作成功地修改了DataFrame。列名右侧的数字表示数据中有多少行在该列中有非空值。我们可以看到大多数列在每一行中都有值,因为非空计数与索引计数相匹配。相比之下,以in_reply_to_*开头的列在 28,034 行数据中只有 10,302 行有非空值。这是因为这些值仅出现在作为回复的帖子中。稍后我们将利用原创帖子和回复之间的这一差异。

在值计数的右侧是列中存储的数据类型。如果在导入数据时没有明确地定义数据类型,pandas 会尽力逻辑地解释这些类型。不幸的是,它主要擅长识别整数和浮动类型。对于其他列,你可以看到它为这些列分配了通用类型object。这是 pandas 表示它并不清楚如何处理该列数据的一种方式。可能是数据类型无法排序(就像user_name列中存储的字符串)或者同一列中可能有两种或更多数据类型(例如in_reply_to_screen_name列,其中某些行有整数值,而其他行则有空值)。在开始任何分析之前,理解底层数据的结构非常重要。你会逐渐熟悉可用的不同数据类型以及何时使用它们,但现在我们不需要做任何更改,因此我们将继续查看输出的最后两行。dtypes属性只是为了方便总结列中的数据类型。我们可以看到,其中一列被确定为浮动数值,三列被确定为整数,其余列则被 pandas 保留为通用object类型。

最后,内存使用行估算了存储整个DataFrame对象所需的内存量。你可以使用这个值大致了解应用程序的数据存储需求,但这里也有一些注意事项。根据你的配置,pandas 可能会通过两种方式之一来计算这个数字。默认情况下,pandas 只是将每列数据类型存储一个值所需的字节数与DataFrame中的行数相乘。例如,一个int64值占用 8 个字节,所以id列大约占用 8 × 28,034 = 224,272 字节(约 224KB)。通过对每一列重复这个过程并汇总结果,pandas 快速估算内存使用情况。问题是某些数据类型(比如object类型)没有最大大小,因此 pandas 只能猜测这些类型分配的最小空间。这就是为什么内存使用后面会有一个+符号的原因。

可视化社交网络

在定义了post_df对象后,你可以分析数据结构并选择用于图形定义的字段。我们将定义一个节点 ID u,作为使用该网络的唯一用户账户。user_iduser_screen_name具有 1:1 的关系,所以两者都是节点 ID 的良好候选字段。user_screen_name字段使得图形更加美观,但user_id可能更适合自动化系统——例如,用于通过 ID 查找用户个人信息的网络分析结果。为了让图形更具吸引力和记忆性,我们将使用user_screen_name字段;毕竟,看着一堆随机生成的 ID 可没那么有趣。

对于边,我们会查看两位用户是否在一条帖子上进行了互动,具体见 Listing 5-4。

❶ G = nx.DiGraph()
❷ for idx in post_df.index:
  ❸ row = post_df.loc[idx]
  ❹ G.add_edge(
      row["in_reply_to_screen_name"], row["user_screen_name"],
    ❺ capacity=len(row["content"])
    )
print(len(G.nodes))

Listing 5-4:将帖子数据表示为有向图

in_reply_to_*字段使我们能够看到某条帖子是否是对早期帖子的回复(显然是来自其他用户)。当用户 B 回复用户 A 的帖子时,我们将把它视为它们之间的一个边,e[(a→b)]。我将继续讨论关于边的内容,以及如何解读它们。

首先,我们从之前定义的DataFrame对象创建一个有向图❶。

我们遍历post_df对象中的每个索引❷,并使用DataFrame.loc函数逐行检索❸。每当一个用户转发另一个用户的帖子时,我们会在图中添加一条边❹。创建原始帖子的用户(源节点)保存在in_reply_to_account_id字段中,回应的用户(终端节点)保存在user_screen_name字段中。接下来,我们将帖子文本的长度作为一个名为capacity的特定边权重形式包含在内❺。这是衡量帖子中包含信息的一个非常简单的方式,稍后我们会详细讨论。最后,我们可以打印图节点列表的长度,确认我们已经将 85 个帖子对象添加到图中。

Figure 5-1 展示了从 Listing 5-4 生成的图的 3D 表示。

每个点都是一个节点,代表网络中的不同用户,而每条虚线则是表示两个用户之间的帖子互动的边。没有回复的帖子不会在图中创建边,因此这里没有可视化出来。尽管数据量很大,图看起来一开始有些混乱,但其实还是有一些可以总结的地方。例如,你可以看到这是一个高度连接的网络。观察外围的节点,你会发现大多数用户有很多条边指向不同的其他用户,这意味着他们在某个时刻通过帖子进行了互动。还要注意,有些节点的边远比其他节点多。就像我们在上一章对计算机网络的分析一样,接下来我们将开始理清这片连接的云,看看能否从中得出一些与我们研究相关的有趣观察。

图 5-1:社交网络图的 3D 可视化

网络分析见解

在构建了我们的图后,我们可以将注意力转向我们的研究问题,首先探讨网络中传播了多少信息。

计算信息传播

计算某物所包含的信息量是一个古老的问题,背后有大量深奥的数学研究。大多数真正有用的方法都涉及一个叫做信息熵的概念,并深入测量某个值(如一个短语)随机出现的概率。这些度量通常用数学语言来描述是相当复杂的,并且需要展开一整篇关于语言学和马尔可夫链的讨论。相反,我选择了一个粗略的替代方法——文本长度。本质上,每个帖子被当作一个数据单元处理,帖子的总信息在每次互动时交换。当用户回复帖子时,我们就认为信息被传播了。

我们将采用与在 NetworkX 中使用的不同的信息交换率方法(参见《分析机器如何在网络中互动》,《第四章》)。相反,我们将考虑剩余信息(RI)得分,即网络中新增的信息量与消耗的信息量之间的差异。例如,您可以将本地图书馆的剩余信息视为它拥有的所有书籍与该地区人们已经阅读的书籍之间的差异。很可能有一些冷门书籍静静地待在书架上,等待某一天有人需要它们。社交网络如 Mastodon 也是如此。当用户创建新帖子时,他们向网络中添加潜在信息——也就是说,等待其他用户发现的信息。当用户回复现有帖子时,潜在信息通过信息交换转化为动能信息:信息通过阅读和回复原始信息的行为从一个用户流向另一个用户。在这种情况下,信息通过有向边e从起始用户流向终端用户,因此边集p也可以视为动能交换的集合。

然后,您可以将问题“传播了多少信息?”重新表述为“在多少潜在信息之后,某些信息有可能转化为动能信息?”或者简单地说,“需要多少帖子才能让别人阅读并回复?”我们可以通过计算没有回复的原创帖子(o)与有回复的原创帖子(p)的比率来回答这个问题;这给出了 RI 得分。暂时假设一整个帖子是一个信息单位,因此对于每条边,信息从u交换到v。这是一种平衡交换,其中帖子中的所有(且仅有的)信息都被传递。如果接收信息的节点每次只能接收一半的信息,那将是不平衡交换,因为发送方可以发送更多信息,而接收方无法处理。

使用这些定义,你可以通过比较潜在信息和动能信息的交换比例,观察信息在整个网络中传播的整体趋势。公式RI = |p| / |o| 描述了所有交换发生后,网络中剩余的潜在信息量。结果告诉你大概需要向网络中添加多少信息,才能使其中一部分被另一个用户消费(通过阅读和回复)。如果网络中的每一条帖子都有回应,你将得到 RI 分数为 0 / n = 0。当网络中没有剩余信息时,你需要添加一条信息才能被其他用户消费。一个没有回复(全部为原创消息)的网络,其 RI 分数为 n / 0 = NaN,这意味着网络中只有剩余信息,表示没有已知的潜在信息会变成动能信息。如果原创帖子是回复帖子的两倍,比例为 2:1——每创作两条帖子,就会有一条得到回复。在另一个 RI 分数为 6(6:1 比例)的网络中,只有六分之一的帖子得到回复,意味着信息流的传播更加难以进行。

列表 5-5 使用DataFrame对象post_df计算示例网络的 RI 分数。

❶ o_posts = post_df[post_df["in_reply_to_screen_name"].isna() == True]
r_posts = post_df[post_df["in_reply_to_id"].isna() == False]
if len(r_posts.index.to_list()) != 0:
  ❷ replied_to = r_posts["in_reply_to_id"].values
  ❸ o_no_r = o_posts.loc[o_posts["id"].isin(replied_to) == False]
    p_len = float(len(o_no_r.index.to_list()))
    o_len = float(len(o_posts.index.to_list()) - p_len)
  ❹ info_exchange = float(p_len / o_len)
else:
    info_exchange = -1
print("The RI score is: %.4f " % info_exchange)

列表 5-5:将 RI 算法应用于示例数据

为了衡量网络中潜在信息和动能信息的量,我们将原创帖子(没有in_reply_to_id值的帖子)收集到o_posts ❶ 中,并将转发帖子收集到r_posts中。我们将没有收到回复的原创帖子(p)分离到o_no_r ❸中,并将收到回复的原创帖子(o)分离到o_posts中,通过从回复列表中收集包含回复的帖子 ID ❷,并创建一个排除replied_to帖子的新的列表。o_no_r中的帖子代表在所有交换发生后网络中剩余的潜在信息。最后,我们取o_no_ro_posts长度的比值来计算 RI 分数 ❹。对于示例数据,结果应约为2.6358,表明每收到一条回复,大约有三条原创帖子被创建。

识别团体和最有影响力的用户

网络分析的一个关键方面是检测嵌套在大网络中的较小社区或团体。回顾第三章,团体是指一组相互直接连接的节点。在我们的社交网络中,这代表了一群彼此熟悉并且曾经互动过的用户。

让我们开始寻找一些团体,首先通过清理数据集并显示图表来进行。团体只有在节点与其他节点有连接时才有意义,所以首先我们需要清理数据,只包括那些有回复的帖子。我们可以像这样从DataFrame中删除没有回复的帖子:

r_posts = post_df[post_df["in_reply_to_id"].isna() == False]

所有in_reply_to_id字段已填充的帖子将被归类到r_posts对象中。我们已经在第三章从理论角度讨论了第二个研究问题:“这个网络中存在哪些社交圈?”所以现在让我们应用这些知识,来理解这个网络的潜在结构。社交圈是节点子集u,其中所有节点u彼此之间都是直接连接的,因此,如果我们假设用户会阅读对其帖子的回复,那么我们可以合理地将有向图转化为无向图,以便识别这些社交圈。清单 5-6 会转换图形,并将社交圈作为列表找到。我们将继续使用有向图进行分析,并结合无向图中的社交圈列表。

uG = nx.to_undirected(G)
cliques = list(nx.algorithms.clique.find_cliques(uG))

清单 5-6:转换为无向图以寻找社交圈

网络中的社交圈很有趣,因为它们提供了用户互动的图景。较大的社交圈通常代表有某种共同联系的用户;它们可以揭示联盟的形成,甚至预测断裂。社交圈本身也可能很有趣:它们告诉你谁认识谁。例如。然而,真正有价值的分析是当你开始分析不同社交圈的成员时,你才会真正获得洞察。你可能会识别出社交圈的领导者,看看谁对网络中其他成员有影响力或地位。正是我们将要做的事情:我们将运用对网络中潜在社交圈的理解,找到哪些群体包含了我们类似 Mastodon 网络中最有影响力的用户。

在这种情况下,节点的出度表示其他用户回复原始节点所发布帖子的次数。一个具有较高出度的节点可以被视为“更受欢迎”,因为这些帖子通常会引发更多的回应。通过识别与这个受欢迎节点接近的节点,我们可以聚焦于潜在的影响者。清单 5-7 找到有向图中出度最高的节点,然后找到包含该节点的社交圈。

❶ deg_ct = G.out_degree()
sorted_deg = sorted(deg_ct, key=lambda kv: kv[1])
top_source = sorted_deg[-1]
❷ source_cliques = [c for c in cliques if top_source[0] in c]
❸ sG = G.subgraph(source_cliques[0])

清单 5-7:查找具有最高出度节点的所有最大社交圈

首先,我们获取有向图中所有节点的出度 ❶。在分析关系时,有时你可能想要量化节点之间连接的强度以及其余数据。例如,如果你知道网络中有两个用户结婚了,你可能想要赋予它们之间的边更高的权重,而不是两个同事之间的边。在清单 5-3 中,我们通过捕捉文本的长度作为交换数据量的粗略度量。现在我们可以利用这些信息来评估用户之间交流的质量。为了考虑边的质量以及数量,我们将简单的出度度量替换为加权出度度量,像是 Dijkstra 算法(正如我在第四章中提到的那样,你通过显式传递weight参数给最短路径算法来实现)。在按出度数升序排列节点后,我们选择最后一项,即作为帖子来源的目标节点,接着使用列表推导式 ❷ 提取包含目标节点的团体。

图 5-2 显示了通过选择这些团体 ❸ 中的第一个所创建的子图。

图 5-2:用户最回应的团体子图

流行用户dannyhoover向图中的每个节点发送出边。用户michaelcruzfalvarez回复了最多其他团体成员的帖子。你可以推测,dannyhoover在这些团体成员中比michaelcruzfalvarez更具影响力。但这并不意味着这两位用户在其他情境下不具影响力。记住,在处理子图时,你获得的信息总是与子图相关,而不是整个图。

对于第三个研究问题,“谁是三个最有影响力的用户?”,我们只需扩展清单 5-7 中的代码,考虑排名前三的源节点。影响力用户是那些能增加更可能引发动态交换的潜在信息的用户。作为练习,尝试确定前三个影响力节点是否在同一个团体中。你能从结果中推断出什么?

寻找最受影响的用户

接下来的问题探讨了图中的反向关系;即,“谁是三个最受影响的用户?”与具有最高入度的节点相关。如果你考虑我们对这个网络中影响力的定义,影响者是那些发布原创帖子,且这些帖子可能会得到一个或多个用户回应的人。相反,受影响较大的用户是那些回复大量其他用户原创帖子的用户。幸运的是,代码与清单 5-7 非常相似。只需将G.in_degree替换为G.out_degree,就能生成类似于图 5-3 中的图。

图 5-3:寻找最有影响力的用户

用户juliekennedy负责网络中大部分的动能信息交换,这意味着他们回复了最多的用户。根据我们的假设,回应帖子的人在某种程度上已经受到了影响(至少足以做出回应),我们可以得出结论,用户juliekennedy受到了最多用户的影响。当然,你可以自由地(而且可能应该)对这一假设的有效性进行辩论。我们正在处理一个安全领域,你必须准备好为你在分析中建立的假设进行辩护。在分析像人类互动这样复杂的事物时,请记住,我们所能做出的主张是有限制的,准确性和有效性也有其界限。

使用基于主题的信息交换

偏离一下我们的研究问题,我们可以通过基于主题的信息交换来回答之前关于影响力的两个问题,具体来说,我们在此过程中考虑某一特定情境或主题下最具影响力和受影响的用户。例如,我们可能会考虑最具影响力的心脏外科医生或最具影响力的黑客。通过用情境实例来分析影响力和受欢迎程度,我们可以更深入地了解我们记录的互动内容。简单来说,我们可以回答“这些用户互动到底是什么?”我们会找出某些特定主题(如环境和政治)下最具影响力和受影响的用户,但你同样可以很容易地扩展这一原则,用于寻找讨论当前事件或其他感兴趣话题的用户。

对于基于主题的信息交换,我们使用超链接诱导主题搜索HITS,也称为中心和权威)算法,它用于分析有向图中的链接关系^1。最初为互联网搜索引擎设计,用来根据与特定主题的相关性对网页进行评分,HITS 已被应用于许多其他类型的链接分析。在安全性和社交网络分析方面,HITS 可以为通用影响力度量(如信息交换比率,IER)提供有用的背景。例如,安全研究人员曾利用 Twitter 追踪与孟买恐怖袭击相关的信息^2,通过分析与袭击相关的话题并确定哪些用户似乎对事件有最权威的理解。

原始算法背后的直觉非常简单:某些站点,被称为中心节点,充当大型网站目录。页面按照与查询主题的相关性排序。一个好的中心节点是指向许多不同主题的其他页面。如果多个中心节点指向同一源页面,那么该页面被视为该主题的权威页面。换句话说,权威节点是指被许多不同中心节点链接的节点。中心节点的得分越高,节点的权威性就越强。一个中心节点连接的权威节点越多,其中心得分越高。现代搜索引擎就是典型的中心节点。这些站点并不是它们所收录的某一特定主题的权威,但它们可以将用户引导到其他权威的站点。

在我们的网络中,中心节点将是一个其帖子被大量权威用户转发的用户。信息流的另一端是权威节点,等同于从多个优质信息中心节点转发信息的用户。NetworkX 在底层依赖 SciPy 库将图转换为稀疏邻接矩阵(一种列表,记录图中每个可能的连接是否存在)。反过来,SciPy 依赖 NumPy 处理矩阵数学。不幸的是,这一依赖链可能会很脆弱。根据你安装包的方式,运行 Mastodon_network.ipynb 文件时,可能会遇到类似 module 'scipy.sparse' has no attribute 'coo_array' 的属性错误。我通过使用以下命令安装 NetworkX 版本 2.6.3 暂时解决了这个问题:

conda install -y networkx=2.6.3

HITS 算法会在一个相关节点子集上迭代执行,通常这个子集是通过某种搜索算法返回的。在每次迭代中,算法会重新计算每个节点的中心得分和权威得分这两个实数值。由于一个好的中心节点的得分应当随着每次迭代而增加,因此它给予每个权威节点的得分也会增加,反之亦然。最终输出的是子集内每个节点的两个得分。

列表 5-8 展示了一种方法,用于查找与包含单词environment的帖子相关的中心节点和权威节点,再次使用列表 5-2 中的DataFrame对象。

❶ post_df["content"] = post_df["content"].str.lower()
❷ env = post_df[post_df["content"].str.contains("environment")]
❸ repl = post_df[post_df["in_reply_to_id"].isin(env["id"].values)]
hG = nx.DiGraph()
for idx in repl.index:
    row = repl.loc[idx]
  ❹ hG.add_edge(row["in_reply_to_screen_name"], row["user_screen_name"])
❺ hub_scores, auth_scores = nx.hits(hG, max_iter=1000, tol=0.01)

列表 5-8:构建基于主题的子图并运行 HITS 算法

我们首先将帖子文本转换为小写 ❶(这样我们可以进行不区分大小写的匹配),然后使用内置的 pandas contains 函数根据文本内容查找相关行,检索所有包含相关根词的帖子 ❷。这还会匹配如 environmental、environmentalist 等词。我们使用每一行的帖子 ID 提取相关帖子的回复集合 ❸。然后,我们遍历每一条回复,并在结果子图中创建一个有向边,表示相关主题的影响流 ❹。

最后,我们使用得到的子图计算 HITS 中心和权威得分❺。传递给networkx.hits函数的max_iter参数(NetworkX 核心库的一部分)控制算法在代码未收敛到解时最大迭代次数(有关 HITS 算法如何收敛的描述,请参见 NetworkX 文档)。tol参数控制用于检查收敛性的误差容忍度。如果算法未能在容忍度和最大迭代次数内收敛到答案,则会引发PowerIterationFailedConvergence异常。

算法从假设所有节点的中心得分和权威得分都为 1 开始。在每个后续步骤中,它计算两个更新规则:

更新权威得分

更新每个节点的权威得分,使其等于指向它的每个节点的中心得分之和。也就是说,通过转发被认为是信息中心的用户的消息,某个节点会获得更高的权威得分。其表示为:

其中 n 是指向 u 的传入引用数量,vith 边的对端节点。

更新中心得分

更新每个节点的中心得分,使其等于指向它的每个节点的权威得分之和。在我们的示例中,通过写作被被认为是该主题权威的节点转发的帖子,某个节点会获得较高的中心得分。其表示为:

其中 nu 的外向引用数量,vith 边的对端节点。

现在你可以从给定主题的角度重新审视第二和第三个研究问题。例如,“环境主题的前三个中心节点是谁?”以及“政治主题的前三个权威节点是谁?”图 5-4 中的主题子图展示了我们样本数据的结果。

图 5-4:环境和政治主题子图示例

与其标记节点,我使用了nx.spring_layout函数来直观地绘制两个主题的影响结构。根据文档说明,

[Spring 布局]算法模拟了一个基于力的网络表示,将边视为弹簧将节点拉近,同时将节点视为排斥物体,有时称为反重力力。仿真将持续进行,直到位置接近平衡。

这样做的效果是,依据其他节点的相对连接性,高度连接的节点会更倾向于向中心移动。靠近中心的节点对更多用户产生了影响,因此其他节点被推得更远。你可以看到右侧的政治图(图 5-4)中,图的边缘有更多小的聚集影响,只有少数节点显示出比其他节点更大的影响力。而左侧的环境图则显示了一个明显的有影响力的用户位于中心,然后是几组较小的本地影响力集群分布在边缘。在使用spring_layout函数时,请记住初始位置是随机的,因此生成的图是随机的(stochastic)。重新运行代码可能会导致不同的可视化布局,但最具影响力的节点总会将其他节点推得比影响力较小的节点更远。

运行 HITS 算法后,你应该会发现环境领域的前三个枢纽(按枢纽得分降序排列)是williamclarkevictoria73nromero。政治领域的前三个权威(同样按权威得分降序排列)是wernerbriannatriverasusanjohnson。请记住,HITS 算法生成的得分仅与主题子集相关。一个在“宠物食品”领域具有高权威得分的节点,在“编程”领域的得分可能会有所不同。

在本章开始时,我提到过社交网络连接和影响力如何被用来预测恶意内容的传播;这就是你第一个真正可以使用的方法。许多恶意软件通过社交网络消息附件传播。一旦你在网络中识别出恶意消息(并提取一些有用的主题信息),你可以利用 HITS 算法预测哪些用户更可能响应该消息。通过这样做,你可以按重要性降序处理风险。一个现实世界的例子发生在我修订本章时。在 COVID-19 大流行的恐慌高峰期,攻击者利用一个被感染的跟踪地图欺骗关注的用户访问恶意网站。一旦这个消息被披露(krebsonsecurity.com/2020/03/live-coronavirus-map-used-to-spread-malware),安全团队使用 HITS 算法追踪哪些用户(如果有的话)可能受到了影响。

网络组织分析

我们想要回答的最终问题——“谁能引入最多的新连接?”——虽然有些复杂,但仍然非常重要。研究人员和分析师在分析网络组织时,通常会使用这类信息,网络的范围从街头帮派到军事营地——任何地方,个体可能不会直接互动,但会有一些共同的监管“上层”负责。例如,A 单位的一名士兵可能会把敌军部队的动向信息传递给单位指挥官,后者再将信息转发给基地指挥官。基地指挥官随时与几位不同单位的指挥官保持联系,可能会将信息发送给 B 单位的另一位指挥官,该指挥官随后将采取行动拦截敌军。在美国,这种指挥链体现了一个可以追溯到总统办公室(作为总司令)一直到每个新兵的节点祖先结构。通过检查哪些节点能够促进大量当前未连接的节点之间的连接,你可以开始理解每个人在等级结构中的重要性。

图 5-5 展示了一个你可能更熟悉的树形结构示例——公司组织图。

图 5-5:来自组织图的示例树

这棵树的根是位于顶端的 CEO,在他下面是三位直接向他汇报的经理。每位经理下方是构成其团队的下属。理解社会结构中的影响力对规划(或规避)与人类互动相关的安全控制至关重要,例如社交工程;社交工程师直观地利用这一概念来获得其他员工的信任。简单来说,如果你能说服一位有影响力的人介绍你,你就能绕过大多数阻力。当然,如果分公司经理能够做你所需的介绍,你是不会直接联系大型公司的 CEO 的。你和你希望被介绍的对象之间的第一个共同节点就是最低公共祖先(LCA)

为了确定 LCA(最低公共祖先),我们首先需要定义节点祖先,它与树的关系(而不是家谱)。在图论中,树是一种特殊类型的图结构,其中任意两个节点之间有且只有一条路径(mathworld.wolfram.com/Tree.html)。树的起始节点是根节点;子节点称为分支节点,除非某个子分支没有自己的分支节点(即死胡同),在这种情况下,它被称为叶节点。

根据定义,简单图没有方向性和环。多重树将简单图的概念扩展到包括方向性,从而形成有向无环图(DAG)。这个看似简单的变化赋予了图结构许多有趣的属性。例如,DAG 具有拓扑排序:节点按顺序排列,使得根节点的值低于叶节点。DAG 是所有图结构中研究最深入的之一,因为它们在自然界中频繁出现。从树木和植物的分支、人体中的血管到河流,再到大多数计算机程序的结构,DAG 可以表示大量的自然和人工系统。在我们的案例中,使用 DAG 来表示节点之间的关系,将使我们能够在社交网络中编码一个成员等级结构。

任意多重树的节点祖先在概念和结构上类似于家谱树。然而,节点的顺序依赖于 DAG 的拓扑排序,而不是严格的时间顺序。最有影响力的用户是那些具有一定出度且没有入度的节点(如dannyhoover,比其他用户更有影响力),这些用户形成了不同树的根节点。每个受影响的节点成为树中的一条分支。对于每个分支节点,出度边再次作为分支添加。分支继续,直到所有节点都被放置。这样就形成了具有零出度和一定入度的叶节点(这些用户是最受网络中其他成员影响的)。以这种方式对节点进行排序,可以帮助你了解影响的流动方向。

从形式上讲,节点u的祖先是任何其他节点v,使得在图中从vu存在有向路径,或者用更代数的方式写作:

祖先(u) = ( u ← v ) ∈ E

两个节点(uv)的公共祖先是那些具有指向uv的有向路径的节点x,这些节点属于边集的交集:

CommAnc( u ∧ v ) = ( x → u ∈ E ) ∪ ( x → v ∈ E )

两个节点(uv)的 LCA(最近公共祖先)是指从两个节点到公共祖先的最短路径距离的节点,这也是从图的根节点到该祖先的最大路径长度。例如,你和你的表兄妹有一些相同的曾祖父母。然而,你们也有一些相同的祖父母。虽然曾祖父母和祖父母都是你的祖先,但由于祖父母距离你这一代更近,所以他们是你的 LCA。图 5-6 展示了同一棵树上祖先的两个例子。

图 5-6:一般的祖先示意图

在每个树中,带有虚线轮廓的阴影节点是另外两个阴影节点的 LCA。在左侧,节点DE的 LCA 是树的根节点A。在右侧,虽然节点A仍然是一个共同的祖先,但节点B离根节点更远,因此是 LCA。用信息安全的角度来思考,两个节点的 LCA 是它们之间最近的潜在枢纽点。如果节点G的用户想要认识节点E的用户,他们可以请求节点B的用户进行介绍。在清单 5-9 中,我们统计了每个节点作为其他节点对的 LCA 出现的次数。

❶ ancestors = list(nx.all_pairs_lowest_common_ancestor(G))
pred_count = {}
for p, lca in ancestors:
  ❷ if p not in G.edges():
        if lca in pred_count.keys():
          ❸ pred_count[lca] += 1
        else:
          ❹ pred_count[lca] = 1
sorted_pred = sorted(pred_count.items(), key=lambda kv: kv[1], reverse=True)

for k in sorted_pred[0:5]:
    print("%s can bridge %d new connection" % (k[0], k[1]))

清单 5-9:计算所有节点的 LCA 出现次数

首先,我们使用 NetworkX 函数nx.all_pairs_lowest_common_ancestor❶生成祖先列表,该函数返回一个字典,其中键是图中的一对节点,值是该节点对的 LCA 节点。填充好ancestors列表后,我们使用for循环将节点对赋值给变量p,并将结果的祖先赋值给变量lca,以便计算lca可以连接多少个节点。我们忽略有边连接的节点对,因为其中一个节点是另一个节点的直接祖先❷。例如,图 5-6 中的节点对BE可以忽略,尽管 NetworkX 函数生成了该对节点的 LCA。对于每个没有直接边连接的节点对,我们检查其lca是否在pred_count字典中,该字典统计了节点作为其他两个节点的 LCA 出现的次数。如果 LCA 节点已经在字典中,则将计数加 1❸。否则,创建一个新条目,值为1❹。运行此代码后,将打印出前五个用户以及他们可以潜在连接的节点数,如清单 5-10 所示。

georgejohnson can bridge 444 new connection
dannyhoover can bridge 444 new connection
vkhan can bridge 372 new connection
judith20 can bridge 336 new connection
david49 can bridge 216 new connection

清单 5-10:LCA 分析的结果

根用户dannyhoover排名并列第一,且可以在网络中潜在地建立 444 个新连接。由于我们已经认为该用户非常有影响力,这个结果并不意外。由于他们位于树的根部,这也意味着他们是所有节点对的最后一个 LCA(最近公共祖先),如果没有找到其他祖先。因此,这个结果可能不像第二和第三名那么有趣。用户georgejohnsondannyhoover获得相同的分数这一点很有意思,可能表明数据中有两个值得研究的结构。

用户judith20可以连接 336 个节点。作为练习,检查该用户如何融入树形结构中。谁影响了他们的活动(入度边)?他们又影响了谁(出度边)?他们在哪些中心性指标上得分最高?

概念验证:社交网络分析

本章的概念验证代码位于书籍资源中的social_network/post_graph.py文件,它允许你将个人时间线的帖子数据捕获为 JSON 数据,您可以使用本书中展示的方法进行分析。

你需要在选择的 Mastodon 实例上注册一个账户(我使用的是 defcon.social)。然后,你需要为自己的 API 凭证注册一个应用程序(docs.joinmastodon.org/client/token)。注册应用程序后,你将获得一个 API 令牌和 API 令牌密钥,用于标识你的账户下的特定应用程序,并授予访问授权功能(如点赞帖子和关注用户)的权限。根据你选择的 Mastodon 实例,可能需要回答一些问题以符合不同的使用场景;否则,你只需在创建令牌时定义访问令牌的范围。许多 Mastodon 实例对研究人员很友好,只要你计划保护个人数据的隐私。

你将获得一个唯一的 API 密钥,用于标识你的 API 账户与 Mastodon 实例配对,并获得一个 API 密钥秘密,它应像其他加密密钥一样受到保护。

注册后,你可以通过 Python Mastodon 库使用 API 抓取自己的时间线。请参考 Mastodon 库文档(mastodonpy.readthedocs.io/en/stable)和 Mastodon API 文档(docs.joinmastodon.org/api)了解可以获取的数据以及如何使用此库访问这些数据。

列表 5-11 展示了概念验证代码。

❶ from mastodon import Mastodon
import pandas as pd

❷ ACCESS_TOKEN = `"YOUR-TOKEN-HERE"`
BASE_URL = "https://defcon.social"
❸ m = Mastodon(access_token=ACCESS_TOKEN, api_base_url=BASE_URL)

❹ timeline_data = m.timeline(timeline="public")

df = pd.DataFrame(timeline_data)
df["id"] = df["id"].astype(dtype=str)
df["in_reply_to_id"] = df["in_reply_to_id"].astype(dtype=str)
df["in_reply_to_account_id"] = df["in_reply_to_account_id"].astype(dtype=str)

print(df.info())
❺ df.to_csv("mastodon_timeline.csv")

列表 5-11:将 Mastodon 公共时间线数据捕获到 CSV 文件

首先,我们导入mastodon库❶。获取 API 凭证后,我们通过访问令牌和基本 URL 修改模板文件❷,并从终端运行它。代码使用这些凭证创建一个经过身份验证的 API 对象❸,该对象用于获取时间线数据❹,这些数据方便地以字典格式提供,适合进行 JSON 编码。我们遍历这些结果并将它们写入输出的 CSV 文件❺。

现在,你可以使用类似于列表 5-2 和 5-4 中的代码将数据读取回 pandas DataFrame,然后将其塑造成重要特征,最后使用 NetworkX 构建相关的有向(或无向)图。你还可以通过将这段代码与数据处理管道结合起来,在几乎实时的情况下分析状态信息,从而绕过写入中间文件的过程。我们将在第三部分中讨论处理管道。

社交网络分析的阴暗面

希望现在你对别人如何快速且轻松地建立社会生活地图有了概念。需要记住的一个重要点是,像地图一样,社交网络图需要解读。当我们解读社交网络信息时,我们不可避免地是通过自己的社会偏见来观察数据。问题的核心是,我们试图将一个高度复杂、多面性的难题——比如人们互动背后的动机——简化为一个严格控制且明确定义的数学模型。为了做到这一点,我们必须基于自己的社会经验应用选择的启发式方法。例如,我之前提到过,你可能会希望将已婚夫妇之间的互动加权比同事之间的互动更高。这展示了我自己的启发式偏见,它源于我的经验、教育和理解,但不一定反映每个人的实际情况。在构建 SNA 模型时,你需要做出许多这样的假设,理解你在何时、何地以及多少程度上允许自己的偏见影响分析至关重要。这也是我推荐与团队一起进行 SNA 的主要原因之一。同行评审,尤其是来自不同背景的同行评审,是解决单一视角解释带来的问题的最佳方法之一。

我推荐保持谨慎的另一个原因是,社会网络分析(SNA)会引发道德和伦理问题。它或许是应用数学在安全领域中的“黑暗艺术”,主要是因为当它被滥用时,可能会产生非常真实且危险的后果。SNA 曾被暴政政府用来攻击异见者、威胁举报人以及操控社会民众。不幸的是,并非所有伦理上有疑问的 SNA 使用方式都容易被发现。有一些工具和网站专门设计用来更容易收集某人的公开(有时是私人)信息。我们生活在一个不断努力平衡隐私与开放的世界中。小世界实验可以用来将电影与凯文·贝肯(Kevin Bacon)联系起来,也可以将我们每个人与任何数量的犯罪人物和组织联系起来。作为分析师,你有责任理解什么是伦理上和道德上合适的。

摘要

尽管本章使用 Mastodon 作为示例来展示社交网络分析的概念,但这些概念并不固有地与 Mastodon 平台绑定。美国政府和大学研究人员一直在开发不同的技术,通过分析暗网论坛中讨论的回复网络结构来获取信息,以了解暗网信息在多大程度上可以帮助预测现实世界中的网络攻击。^(3) 在他为美国海军撰写的论文《使用社交网络分析追踪、破坏和干扰暗网络》中,Sean Everton 介绍了 SNA 作为一种追踪和破坏犯罪及恐怖分子网络的策略手段。^(4) 这篇论文既是战术性的,也是战略性的入门,我强烈推荐阅读。

当你将自己的社交网络分析(SNA)扩展到实际应用时,你需要参考你所使用的社交网络的 API 文档。如果没有 API(或者平台开始收取高额费用),你可能需要诉诸传统的网页抓取技术来收集所需的数据。这类任务超出了本书的范围,但有许多优秀的材料可以帮助你实现这一点。

到目前为止,我们使用图分析的所有内容都集中在过去。你可以把它看作是描述性安全分析,因为它旨在将事物分类为现在的状态(或者数据捕获时的状态)。然而,预防性安全分析则试图分析未来可能发生的事件,从而希望我们能够提前介入,防止安全事件的发生。为了实现这一目标,我们将使用我最喜欢的模拟算法之一——蒙特卡罗模拟,这是下一章的内容。

第八章:分析社交网络以防止安全事件

在过去的三章图论内容中,我们根据网络在特定时刻的快照构建了图;也就是说,我们使用的是固定的历史数据。但是,回顾过去并响应事件总是让白帽子比黑帽子慢一步。如果我们想了解数据捕获的时间之前或之后发生了什么,我们需要新的分析技术。未来需要预测分析,这是一门数学分支,旨在根据一组已知的观察数据统计地确定未来或过去事件的可能性。目标是阻止安全事件在发生之前就被解决。然而,要实现这一点,我们需要一种方法来预测事物随时间的变化。我们将使用一种特定的算法——蒙特卡罗模拟,来模拟尚未发生的网络活动。虽然本章在社交网络分析的背景下介绍了这一主题,但蒙特卡罗模拟适用于各种不同的主题和网络类型。例如,我曾使用蒙特卡罗模拟预测对手接下来会攻击哪台机器。

在这里,我们将尝试预测以下关于社交网络的问题的答案:

  • 信息从给定节点传播的距离有多远?

  • 哪些节点正在受到其他节点的影响?

  • 哪些连接可能会被切断,从而中断两个节点之间的信息流动?

从安全角度看,这些问题评估社交网络在面对对抗行为时的韧性。它们问:“打破人们之间的关联有多容易?”公司问这些问题是为了确定他们是否能承受失去关键员工、设施或供应商的打击。执法部门在评估犯罪集团时也会问这些问题。^(1) 犯罪分子也会在选择网络钓鱼和其他社会工程攻击目标时问这些问题。^(2)

本章将从定义和构建蒙特卡罗模拟开始。我们将讨论如何应用不同级别的随机性来替代未知数。然后,我们将利用我们构建的蒙特卡罗模拟,预测信息如何在社交网络中传播,从第五章的先前观察数据出发。最后,在本章的概念验证中,我们将看到如何修改我们的模拟,以考虑对抗行为。在本章结束时,你将能够运用图论知识,并将蒙特卡罗模拟应用于预测自己社交网络中不同情境的结果。

使用蒙特卡罗模拟预测攻击

为了让本章其余部分有意义,我们需要在已经涵盖的图论基础上增加一些理论内容。具体来说,我一直在提到模拟这个词,但并没有真正定义它。一般来说,模拟是对现实世界过程的受控仿真。模拟依赖于模型来描述模拟环境中存在的关键特征和行为。模拟代码充当模型的管理者,选择各种操作并在每一步应用它们以推动模型的发展。现代模型和模拟通常使用 C 和 Python 等编程语言的组合来设计,其中 C 用于关键功能,而用户友好的 Python 语法则用于其余部分。幸运的是,所有底层的 C 代码已经为我们处理好了,所以我们可以专注于 Python 接口。

理论上,任何可以简化为数据和方程的现象都可以在计算机上进行模拟。然而,在实践中,模拟是困难的,因为大多数现实世界的过程都受到几乎无限多种因素的影响,而这些因素是无法全部考虑到的。

蒙特卡罗模拟是一种在给定约束条件下,快速收集有关某些看似随机(或者至少难以预测)变量统计数据的方法。与其他预测方法不同,蒙特卡罗模拟基于一组预估的值范围来预测一组结果,而不是依赖固定的输入值。你可能见过蒙特卡罗模拟的结果,这种结果常以风暴路径图的形式展示(有时被称为意大利面模型)。蒙特卡罗模拟在由于随机变量干扰无法确定不同结果的概率时最为有用。蒙特卡罗模拟通过对随机样本进行多次测试,来实现特定结果。它还有助于解释风险和不确定性在预测和预报模型中的影响,因为随机变量的值是通过以前记录的值的分布来选择的。随机值的方差越大,模拟中不同结果的方差也就越大。原则上,蒙特卡罗方法可以用于研究任何具有概率解释的问题。

在安全环境中,我使用了蒙特卡罗模拟来预测并阻止攻击。为了实现这一点,我编写了一些规则,模拟了攻击者之前的决策,并运行了成千上万次的模拟来预测攻击者将会在哪里结束。我的团队创建了一个网络图(类似于上一章中的那个),在其中我们综合考虑了访问难度和机器对攻击者的吸引力(从数据或横向移动的角度来看)。然后,我们进行了模拟,攻击者从我们知道已被攻击的随机机器开始,使用随机过程来判断攻击者是否能够成功地从一台机器移动到另一台。

我们有额外的规则来定义攻击者如何选择机器等,但我们试图回答的问题很简单:经过六天的积极利用,哪些机器被感染的概率最高?用数学术语来说,大数法则告诉我们,通过对某些随机变量的期望值进行积分,可以通过取该变量的独立样本的经验均值(有时叫做样本均值)来进行近似。通俗来说,我们网络仿真测试中被感染概率最高的机器,很可能就是那些实际感染概率最高的机器。这就是我们对“预测”未来的定义:我们可以在一定程度上有信心地陈述每种结果的统计概率。不幸的是,这意味着事情并不总是会按照预测的结果发生。

建模变化要求我们首先有一种方法来描述什么可以发生,什么不能发生。我们将使用一种叫做有限状态机的数学构造来处理这个任务。接着,我们需要为我们的仿真创建一个虚拟世界。NetworkX 将通过提供我们的社交网络图来扮演这个角色。最后,我们需要某种方法来记录不同的事件,以便进行分析。这就是蒙特卡洛算法真正开始发挥作用的地方。让我们从定义每个部分开始,然后通过一些不同的仿真将它们结合起来。

有限状态机

有限状态机FSM 或简称 状态机)是一个假设的机器,在任何给定时刻只能处于有限数量的状态中的一个,其中一个状态是变量的独特配置。如果你想象一块有三个开关的电路板,每种可能的开关配置就代表电路板的一个可能状态。它之所以叫做有限状态机,是因为你可以计算出可能的状态数目。在这个示例的开关板中,如果每个开关可以处于两个可能的位置,那么总共有八种可能的配置,或者说状态,开关板可以处于其中。如果你把这些开关看作是二进制中的位,你可以表示从 000 到 111 的值,或者从 0 到 7 的十进制值。状态机可以响应某些外部输入或决策(例如翻动电路板上的一个开关)从一个状态转换到另一个状态。状态之间的变化叫做过渡

正式地,一个状态机 M 由五元组 M = (Ξ, S, S[0], δ) 定义,其中包含有限个可能的输入(Ξ,输入字母表)、所有可能状态的集合(S)、初始化状态(S[0]),其中 S[0] ∈ S,以及每个有效状态转换之间的条件 δ。我们可以将状态机表示为一个有向图,其中每个节点是机器的潜在状态,每条边是从状态 u 到状态 v 所需的输入。图 6-1 显示了一个简单的有限状态机图,包含五个状态和四个转换输入。

图 6-1:一个简单的有限状态机

看这个图,你可能会感到困惑;毕竟,我刚才说过有四个转换,但这里有九条边(S[0] 和 S[1] 之间以及 S[3] 和 S[4] 之间的双向边算作两条)。这是因为相同的输入可能在多个转换中使用。图 6-1 中的输入 Ξ[3] 和 Ξ[2] 就是这种情况的例子:Ξ[2] 被用来在 S[0] 和 S[1] 之间转换,也在 S[4] 和 S[2] 之间转换,而 Ξ[3] 可以用来从 S[3] 转换到 S[1],或者从 S[1] 转换到 S[2]。把输入 Ξ[2] 想象成一个动作,比如翻动一个特定的开关。根据你当前所处的状态,翻动开关的动作可能会把你带到不同的状态。如果你在 S[0] 中翻动开关,你会到达 S[1]。如果你在 S[4] 中翻动开关,你会到达 S[2]。输入没有改变——它仍然是 Ξ[2]——这说明了输入和状态之间的一个重要关系:相同的输入可能会导致到达不同的状态,具体取决于当前的状态。

有限状态机(FSM)可以是确定性的,意味着每个转换都有一个单一的保证结果,或者是随机的*,意味着输入的结果受随机性影响,并且不保证每次都会产生相同的结果。为了说明这两种类型的有限状态机之间的区别,想象一下捡起一支铅笔。在一个确定性的世界中,试图捡起铅笔总是会成功——或者在有限状态机的术语中,就是转换到你拥有铅笔的状态。而在一个随机的世界中,捡起铅笔可能会有一定的失败概率 0 < p < 1。如果你未能捡起铅笔,你将进入与成功时不同的状态。也许你把铅笔掉到地板上,现在你处于那个状态。这是一个非常简单的例子,但关键是随机有限状态机允许随机性影响结果。这对于概括复杂交互的描述非常有用,因为你不需要理解背后的机制,你只需要衡量可能结果的统计分布,就能近似同样的现象。

你会经常看到在同一个有限状态机(FSM)中混合使用确定性和随机输入。例如,在图 6-1 中的 FSM 中,Ξ[4]是确定性的。如果你处于S[2]状态,输入Ξ[4]会保证将你转换到S[3]状态,没有其他可能的结果。另一方面,Ξ[1]是随机的:如果你处于S[4]状态并选择了动作Ξ[1],你可能会到达S[1]或S[3]。如果没有给出这些结果的概率,通常认为它是均匀随机的。如果给出了概率,则使用概率分布在加权随机选择函数中进行选择。NetworkX 有用于标记边的参数,这在显示概率时很有用,或者正如我在这里做的那样,显示转换名称。你可以在附带的 Jupyter notebook 中看到这些代码的示例。关于使用 FSM 的更详细示例,我强烈推荐查看 Wolfram Alpha。

现在你对 FSM 结构有了更好的理解,我们继续探讨如何利用它,借助一种名为随机游走的算法宝石。随机游走允许我们反复为 FSM 选择随机输入,以基于我们定义的规则自动化这些选择的模拟。

网络建模与随机游走

用数学术语来说,随机游走是一系列在系统内随机选择的步骤(或转换),在经过若干步之后会导致一个随机的最终状态。我喜欢用游客在陌生城市中漫游的比喻。他们可能会走上一段街道,决定转左,再走几个街区,然后决定转身回头。按照定义,这些游走是无规律且不可预测的。随机游走模型的不同版本已经被应用于从经济学到神经学的研究课题,现在也用于信息安全!

我们将应用这种方法来建模人们如何相互传递信息并最终利用网络。然后,我们可以利用这些信息来探索如果改变一些参数(例如攻击者接管一个或多个通信线路)时会发生什么,而无需冒着实际干扰网络的风险。在状态机中随机选择一系列转换,经过n步(T(n))后,系统的状态会根据输入结果更新。随后的决策必须基于新状态,并且在所有状态下并非所有动作都是有效的。从给定状态的有效转换集合用Ξ[(][S][)]表示。在每一步中,从Ξ[(][S][)]中选择一个转换并将其附加到T(n)中。我们可以将其表示为:

状态被更新后,过程会重复,直到所有n步都已完成,或者没有剩余的有效状态转换。最终的终态是将T(n)中定义的随机游走应用于状态机M (M × T(n) = S(Tn*))的结果。

作为一个具体的例子,假设我们定义一个简单的状态机。想象你站在一个大空房间的中心,这是初始状态,S[0]。地板上有一个 7×7 的方格,房间中的位置可以表示为笛卡尔平面上的位置元组(xy)(你的位置是S[0] = (4, 4))。你可以每次移动一步,向前、向后、向左或向右。给定一组任意的指令,你可能会站在房间的任何方格上;因此,每个方格可以视为S中的一个潜在状态。输入[前进后退]构成了输入字母表Ξ。图 6-2 中的两个图展示了相同的均匀随机漫步,左边是二维,右边是三维,步数为n = 10。

图 6-2:二维和三维随机漫步示例

在 3D 示例中,第三个维度是时间(你在房间里移动时实际上不会开始悬浮)。

与均匀随机漫步不同,其中每个输入的发生概率相等,在偏置随机漫步(或仅称为偏置漫步)中,一个或多个输入的发生概率可能会高于其他输入。在偏置漫步中,我们将Ξ扩展为一组元组:(输入概率)。^(3)在每一步中,我们从列表中选择一个输入,使用加权随机选择函数,也就是遵循我们传递给它的概率分布的函数。我们稍后会构建一个这个的版本,但现在的关键是,偏置漫步允许你将关于行为概率的任何先验信息添加到你的模型中。例如,如果你知道有一个恶意行为者在寻找财务信息,你可能会选择将模型的行为偏向于那些有权限访问该类信息的网络节点。

到目前为止,我们已经介绍了什么是状态机,以及如何使用状态机来模拟一系列的选择。由于随机漫步代表在随机状态机(FSM)中做出的一组选择,你可以重新运行模拟,结果可能会有所不同。即使是偏置随机漫步,每次迭代的结果也许会更加可预测,但仍然不完全相同。如果结果总是相同的,那么系统将是确定性的,分析起来也不会有趣。我们感兴趣的是分析模拟结果之间的差异。重复的随机模拟是蒙特卡洛模拟的定义特征,因此在下一节中,我们将通过定义如何运行每个测试并以有意义的方式收集结果,来完成我们的算法。一旦我们获得了拼图的最后一块,我们将开始使用蒙特卡洛模拟预测社交网络的一些可能的未来状态。

蒙特卡洛模拟

我们可以通过简单的掷硬币例子来说明随机游走与蒙特卡洛模拟之间的关系。如果我们掷一枚硬币,并且它落在正面,那么我们从硬币上学到了什么呢?嗯,我们知道在样本量极小(仅为 1)的情况下,硬币会落在正面。那么,你认为这些信息对于预测未来掷硬币结果有多大帮助呢?你能否预测这是公平的硬币还是有陷阱的硬币?答案是不能。这个单一结果并不特别有用——至少现在还不。为了得到更清晰的图景,我们需要将这个测试重复进行较多次,并记录每次的结果。假设我们再掷 99 次硬币,结果每次都落在正面。那么这个结果远远超出了大约 50%的预期,我们就可以断定这枚硬币肯定不公平。

这种情况类似于随机游走和蒙特卡洛模拟之间的关系。蒙特卡洛模拟是重复采样算法的一个子集,它通过多次重复测试来收集统计分布。蒙特卡洛模拟与其他重复采样算法的不同之处在于,它通过重复随机游走来简化在状态机中模拟复杂的交互过程。FSM 中的随机游走就像一个单一的测试——一次掷硬币、一场太空漫步或其他某种单一事件。然后,蒙特卡洛算法会在此基础上增加一层,将这个测试重复进行多次,以收集大量样本数据,从而对未来结果做出准确预测。

蒙特卡洛模拟的一个主要应用是在通用博弈游戏(GGP)领域。GGP 研究者的目标是找到一种通用算法,能够玩任何任意但定义明确的游戏。想想像深蓝(Deep Blue)或更近期的 Alpha Go 这样的系统,但它被设计用来玩国际象棋和围棋,以及西洋跳棋、井字游戏、风险战舰等其他游戏。这个研究领域也扩展到单人游戏(即所谓的益智游戏),例如河内塔。这个自动化系统被称为玩家,需要从一系列潜在动作中决定下一个有效的动作。这个过程被称为目标导向规划。在具有大量潜在状态的状态机中(例如国际象棋比赛),全面搜索所有选项并得出结论是不可行的。因此,玩家需要一种策略,快速权衡可能的选项,以识别有利的选择。蒙特卡洛模拟是研究人员成功应用的一种方法^(4),它通过将每场游戏简化为一个有限长度的随机游走,穿越潜在的游戏状态,然后反复测试这些游走的结果,以达到某个目标条件。

正如我所提到的,安全性往往归结为一个研究人员的进攻知识与另一个研究人员的防守知识之间的对抗。博弈论将这种情况称为零和多人博弈。零和指的是一种情况,即为了让一个玩家赢得积分,另一个玩家必须失去等量的积分。简单来说:如果你赢了,我就必须输,反之亦然。国际象棋是零和博弈最著名的例子,但我们也可以在许多对抗性互动中看到这些条件,比如安全性问题。为了绕过你的安全防护,你的安全防护必须被突破。为了阻止我,你的安全控制必须失败。像斯坦福大学这样的学府已经开设博弈论课程,帮助人们分析他们的安全态势。也有研究人员使用博弈论来模拟攻击与防守场景。^(5) 在我看来,将博弈论程序化地应用于信息安全研究是从现有工具自然发展而来的,而开始这一过程的最简单方式就是使用蒙特卡罗仿真。

当然,这种简化可能会带来一些代价。由于选择的随机性质,蒙特卡罗仿真可能会错过明显的有利决策。你可以通过调整随机游走的次数,以及每次随机游走的最大长度,来略微调节模型的准确性。图 6-3 展示了一个蒙特卡罗仿真示例,类似于图 6-2 中的随机游走。

图 6-3:随机游走蒙特卡罗仿真

每次游走的路径会有不同的阴影色调,这样你就可以看出它们的重叠部分。它们都从同一个位置开始,但随后会沿着不可预测的路径走。

我们将要看的蒙特卡罗仿真是一种依赖于 k 次长度为 n 的随机游走算法,通过状态机 M 来获得结果列表 ζR。结果是每次随机游走得到的终态列表:

为了方便起见,我还输出了每个随机游走所经过的路径:

选择 nk 的值既需要领域知识,也需要统计理论和艺术的结合。对于 n,我们需要选择一个足够大的值,使得我们的模型能够到达有趣的结果状态,而不会产生大量重复数据。对于有大量潜在转移和状态的状态机,你可能需要选择一个 n 值,平衡足够长的路径与合理的程序运行时间。我们的状态机有少量的潜在转移,并且通常会很快到达终态,所以 10 到 20 步之间的小值就足够了。

选择一个好的k值与机器中潜在状态的数量密切相关。你希望尽可能多次运行模拟,以收集支持结果主张的统计数据,因此可能的结果越多,你就越希望多次运行模拟。当你将这样的项目投入生产时,可以使用统计方法来计算确切的样本量,以证明实证主张,这叫做样本量确定。在这里,我们的模拟只有相对较少的终端状态,并且我们只是尝试证明系统的有效性,因此,进行 10 到 25 次运行就足够进行测试。

社交网络模拟

为了回答关于社交网络的研究问题,我们将编写我们自己的Matrix式世界,其中模拟的用户将根据我们设定的系统规则生活。我们选择的规则代表了用户在模拟世界中可以做出的所有决策。我所使用的规则基于数据中已经存在的观察结果(例如,哪些用户曾经沟通过,沟通的主题是什么),以及 2009 年发布的某些链接预测理论的简化版本。^(6) 链接预测理论 试图描述图中的边缘是如何以前形成的,并利用这些信息来预测未来如何形成。

我们设计有限状态机(FSM)规则的目标是准确模拟哪些用户可能会建立连接、解除与其他用户的关联,或将信息传递给他们的连接。接下来,我们将研究如何通过增加一个旨在破坏网络的对立者来增强模拟。这使我们进入了“如果”的模拟领域。如果人力资源部门的负责人突然离开公司会怎么样?如果办公室的路由器崩溃了呢?你会开始看到到处都可以应用模拟的机会。阅读完这个实现后,思考一下我们建立的规则和假设,以及如何通过更加现实的约束和行为来改进模拟。

用户互动建模

为了回答“信息从某个节点传播的范围有多远?”这个问题,我们可以模拟一个消息q在网络中通过从一个用户传递到另一个用户的方式传播,并确定可能接收到该消息的用户数量。我们将通过为消息生成偏置的随机游走来模拟用户之间的交互,从而让消息在节点间传播。假设,目前为止一次只有一份消息副本存在。(处理消息的多个副本请参见“信息流建模”)可以将其想象为一份预算报告在办公室内流转。每个员工阅读报告后,决定是否将其转发给某个同事。由于报告内容敏感,没人被允许复印,因此任何时刻只有一个人可以持有该信息。通过选择一个起始节点并允许消息按概率传播,我们可以模拟报告可能遵循的路径,然后统计最终接收到报告的独特节点数量。所有游走完成后,接收到信息的独特节点的平均数量可以看作是从选定的起始节点开始,信息可能传播到的节点数量。

对于蒙特卡洛模拟,你必须定义作为系统核心的状态机。社交网络图中的节点(即用户)代表消息在有限状态机(FSM)中可能占据的状态。边表示状态之间的潜在转移(基于用户之间的过去通信)。在我们模拟的开始,这些边将保持静态,我们将检查当前时刻网络的状态。(在本章的概念验证中,边会发生变化,以模拟用户在网络中建立和断开连接。)最后,输入字母表定义了有效的过渡操作,模拟节点之间的交互(例如,一个用户将消息传递给另一个用户)。定义 FSM 的输入和转移类似于定义有效的选择以及选择的时机。针对第一个问题,即节点之间传递的信息,输入字母表是[发送, 传递],表示用户在接收到信息后可能采取的两种操作。

首先,我们将初始状态S[0]定义为持有消息的出度最高的节点,这样模拟就能在不同的模拟中最大程度地覆盖更多独特的节点。稍后,我们会衡量从不同节点开始的效果。在任何给定时刻,持有消息的节点是u(q)。

在随机游走的每一步中,节点u(q)会均匀地从两个可能的输入 Ξ = [发送, 传递] 中选择一个。选择输入的先验概率为:

Pr (Ξ[(Send)])表示Send的概率。如果u(q)选择Send,则q将被传递给u(q)的一个均匀随机选择的邻居(我仍然将一个节点的邻居表示为Γ[(u)])。如果选择Pass,则u(q)在该步骤中不做任何操作。

给定邻居v被选中的先验概率是:

这里 v ∈ Γ(u(q))。简单来说,这意味着每个邻居的起始概率是相等的。一个节点的邻居越多,任何一个邻居接收消息的概率就越低。例如,如果u(q)有三个邻居(|Γ(u(q))| = 3),那么m06006r。你也可以将其写成条件概率(Pr(A|B)):

该公式表示,在输入为Send的情况下,某个特定邻居接收到消息的概率为 0.33,即 33%。

假设不考虑Send为选定输入的情况下,消息传播的总体概率定义在前一个公式的分子中。直观地,你可以将其视为每个事件单独发生的概率。更准确地说,消息传播到给定邻居的独立概率(假设有三个邻居)是:

Pr (u(q) → v ) = Pr ( Ξ( Send ) ∧ v(q) ) = 0.5 × 0.33 = 0.165

在生成随机游走之前,我们需要设置仿真环境,如 Listing 6-1 所示。

❶ XI = ["send", None]
❷ k = 10
n = 10
out_deg = G.out_degree()
valkey_sorted = sorted(out_deg, key=lambda x: (x[1], x[0]))
❸ S0 = valkey_sorted[-1][0]

Listing 6-1:蒙特卡洛仿真初始化代码

该代码依赖于图G已使用 Listings 5-2 和 5-4 中的方法进行填充。假设图G已经填充完成,我们从输入字母表XI开始,其中Send使用None表示 ❶,仿真次数k ❷,以及每次仿真的步数n。为了设置起始状态S0 ❸,我们选择出度最高的节点。

延续 Listing 6-1,Listing 6-2 展示了一个确定性的、均匀随机的消息传递蒙特卡洛仿真算法实现。

from random import choice
❶ R = []
❷ for i in range(k):
  ❸ message_at = S0
    Tn = []
  ❹ for j in range(n):
        if choice(XI) is not None:
          ❺ gamma_uq = list(nx.neighbors(G, message_at))
          ❻ if len(gamma_uq) > 1:
                vq = choice(gamma_uq)
                Tn.append((message_at, vq))
                message_at = vq
            elif len(gamma_uq) == 1:
 vq = gamma_uq[0]
                Tn.append((message_at, vq))
                message_at = vq
          ❼ else:
                conc = "Message terminated at node %s in %d steps"
                print( conc % (message_at, len(Tn)))
                break
  ❽ R.append((message_at, Tn))
tot = 0
❾ for end, path in R:
    uniq = unique(path)
    tot = len(uniq) - 1
❿ print(S0, (tot / len(R)) / (len(G.nodes.keys()) - 1))

Listing 6-2:确定性消息传递蒙特卡洛仿真

首先,我们初始化结果列表R ❶,然后使用嵌套的for循环 24 执行k次随机游走,每次游走最多执行n步。

每次游走从节点S0 ❸开始。在每一步,我们收集当前选定节点的邻居 ❺。如果只有一个邻居,则自动选择该邻居。然而,如果存在多个邻居 ❻,我们使用choice函数从中均匀地随机选择一个邻居,然后更新message_at变量。如果消息最终到达一个没有出度的节点 ❼,我们将该节点记录为结果,并用break结束游走。在每次游走结束时,我们将终止节点Tn添加到结果列表 ❽。

我们将可能的信息流距离(或IFDm06008总结为每条路径中独特节点的平均数量(不考虑起始节点)❾,然后通过G中的节点总数(同样不考虑起始节点)❿来标准化 IFD,并将其输出到屏幕上。unique函数简单地将路径减少为仅包含唯一条目的列表。(你可以在本章补充材料的MonteCarloSimulations.ipynb笔记本中的第二个单元格中查看我是如何实现的。你也可以选择使用库中某个版本的代码,例如 NumPy 的unique。)

如果你运行 Listing 6-2 中的代码几次,你会注意到输出结果不一致。虽然S[0]是确定性的,但从那里开始的路径是随机的。你可以通过将vq = choice(gamma_uq)调用替换为一个确定性选择方法(例如,总是将q传递给邻居节点uq)的出度最高的节点),使得这个模型完全确定性。这将是模拟已知的特定行为模式的一个好选项。

要实现一个带有偏置行走的模拟,而不是均匀随机行走,你可以向XI中添加另一个已经存在的元素。通过这样做,你改变了每个输入变量被选择的相对概率。例如,向列表中添加另一个"send"将使Send的概率是Pass的两倍:

为了对每个输入的概率(偏置)进行更精细的控制,可以将简单的choice函数替换为一个可以处理{action: probability}定义字典的函数。(请参见本章末尾的概念验证部分以获取示例。)

恭喜你,你已经使用蒙特卡洛模拟定义了第一个预测模型!这是一个简化的模型,我们依赖均匀选择来生成随机性,并使用一些基本动作,如发送和传递,来描述网络中某个任意消息可能发生的情况。尝试从不同的用户那里启动消息,看看它如何影响消息的传递步骤数以及最终的位置。我们称之为简单模型,因为我们没有包含有关网络历史、消息内容或用户偏好的任何具体信息。我们假设每个节点都有相同的概率将任何消息发送给它能联系到的任何其他节点。虽然像这样的简化假设使代码更容易编写和解释,但它们以牺牲准确性为代价。在下一节中,我们将扩展模型,加入更多关于消息和用户的细节,以更准确地预测我们已经观察到的数据流在网络中的可能流动。

基于主题的影响建模

为了回答“哪些节点受到其他节点的影响?”这个问题,我们将扩展第五章中基于话题的影响调查。回想一下,我们之前通过测量用户与包含相同话题的其他消息的互动,使用超链接引导话题搜索算法(HITS)来衡量每个用户对某一话题的潜在兴趣。如果我们将当前模型以某个特定消息话题(如环境)为基础进行重新构建,我们可以将中心节点和权威信息融入到我们的状态机模型中,从而控制信息交换的概率。在这种情况下,我们将使用用户的 HITS 分数来确定消息被转发的概率,这个概率是基于消息内容的,而不是简单地假设所有消息对于所有用户的转发概率都是相同的。

以这种方式建模消息传播假设用户更有可能转发他们之前已转发过的类似消息。已经转发过涉及特定话题的帖子用户,比没有转发过的用户拥有更高的该话题权威分数,这意味着他们更有可能收到关于该话题的消息。如果你考虑一下在社交网络上看到的内容,你可能会发现人们分享和重新分享的信息中有一个相当常见的主题(这是你可能想稍后挑战的假设之一)。有些人选择分享商业新闻;有些人分享艺术和娱乐;还有些人分享安全相关的内容。

让我们更新之前的实现,比较不同消息类型(qx)的传播方式,这样我们就可以检查不同用户的兴趣,并预测他们未来最有可能转发哪些消息。如果你在为这个网络设计病毒性消息攻击,分析不同的话题并选择传播最远的那个是有意义的。从防御的角度来看,你可以反向分析这条消息,并追踪恶意消息的可能来源。我们将保持用户之间的影响力定义不变(也就是说,用户转发一条消息时,受到了该消息的某种程度影响),但我们的节点将选择要转发的消息,而不是使用发送操作。操作的方式没有改变,因此我选择保持名称,但重新命名可能有助于在你的模型中保持影响方向的清晰。

清单 6-3 展示了运行基于话题的消息传递蒙特卡洛模拟的代码:

import graph_funcs as ext
qx = "environment"
❶ hG = term_subgraph(qx, post_df)
hub_scores, auth_scores = nx.hits(hG, max_iter=1000, tol=0.01)
hub_max = max(hub_scores.values())
S0_i = list(hub_scores.values()).index(hub_max)
❷ S0 = list(hub_scores.keys())[S0_i]

for i in range(k):
    uq = S0
    Tn = []
    for j in range(n):
      ❸ send_msg = ext.hub_send(hub_scores[uq])
        if send_msg:
          ❹ vq = ext.scored_neighbor_select(hG, uq, auth_scores)
            if vq is None:
                conc = "Message terminated at node %s in %d steps"
                print( conc % (uq, len(Tn)))
                break
            else:
                Tn.append((uq, vq))
              ❺ uq = vq
    R.append((uq, Tn))
ended_at = {}
❻ for end, path in R:
    if end in ended_at.keys():
        ended_at[end] += 1
    else:
        ended_at[end] = 1
return (S0, ended_at)

清单 6-3:基于话题的消息传递蒙特卡洛模拟

这段代码打印一个元组(S0, {node: termination_count}),使用与清单 6-1 中定义的相同的kn值,以及基于清单 6-5 中代码的term_subgraph函数❶。

在此模拟中,S[0] ❷ 是所选主题的最大中心度节点(S[0] = max(hub(qx)(G))),而Pr (Ξ[(Send)]) 是消息类型 x 对应的u(q)的中心度分数:Pr (Ξ[(Send)]) = hub**(qx)hub_send函数(在graph_funcs.py文件中定义,随书附带)接受uq的中心度分数并返回uq是否转发该消息❸。hub_send函数基于另一个函数weighted_choice,该函数也包含在graph_funcs.py文件中。在Ξ中仍然只有两种可能的动作,因此Pass的概率等于 1 减去Send的概率:Pr (Ξ[(Pass)]) = 1 – Pr (Ξ[(Send)])。选择给定邻居的概率是该邻居的归一化权威分数,基于消息类型qxPr(v(qx)) = auth(qx)(v))。

如果消息已发送,我们使用scored_neighbor_select函数(同样在graph_funcs.py文件中定义,并基于weighted_choice函数)选择邻居❹。如果返回了邻居,我们将在路径Tn中添加发送者和接收者之间的边,并更新消息的位置❺;否则,我们通过break语句终止模拟。

如果我们假设一条消息在一个节点结束会产生一些影响,我们可以计算消息结束在特定用户处的次数,并认为具有最高计数的节点最有可能受到给定用户发出的消息类型的影响。这直观地意味着该用户可能最终会在某个时刻转发这条消息。可以说,所有的路都通向家。为了找到这个节点,我们遍历结束位置并统计结果,构建{node: termination_count}字典❻,然后打印结果。这构成了蒙特卡洛模拟的一个运行。

我们希望收集多个运行结果并对其进行平均,以获得最准确的预测,因此我们将代码封装在一个名为run_sim_2的函数定义中,该函数将接收主题列表和 Mastodon 帖子数据作为参数。(你可以在MonteCarloSimulation.ipynb笔记本的第 4 个单元格中看到run_sim_2函数。)最后,我们返回源节点和包含消息终止用户的字典,以便我们可以收集结果,直到准备好进行分析。让我们在循环中调用这个新定义的函数来收集合理的样本大小。列表 6-4 展示了如何收集样本并对其进行平均,以便得到最终输出。

all_runs = {}
started_at = ""
❶ for run_i in range(0, 10):
  ❷ started_at, results = run_sim(["environment"], post_df)
  ❸ for ks in results:
        if ks in all_runs.keys():
            all_runs[ks] += results[ks]
 else:
            all_runs[ks] = results[ks]
❹ for node in all_runs.keys():
    if node != started_at:
        print("%s influenced %s an average of %.2f times" % (
            started_at, node, all_runs[node]/10
        ))

列表 6-4:平均蒙特卡洛模拟结果

以下是对模拟 10 次运行结果的平均值:

gutierrezjamie influenced iwatkins an average of 1.80 times
gutierrezjamie influenced hartmanmatthew an average of 2.20 times
gutierrezjamie influenced shannon42 an average of 0.90 times
gutierrezjamie influenced daniel99 an average of 0.70 times
gutierrezjamie influenced garciajames an average of 1.00 times
gutierrezjamie influenced grosslinda an average of 0.30 times

使用这些结果,我们可以断言用户gutierrezjamie最有可能在环境话题上影响hartmanmatthew。这里重要的不是数字本身,而是数字的相对大小,因此你也可能得出结论,shannon42grosslinda更可能三倍转发该信息。当然,这只是一次小规模模拟的结果。在像信息流和影响力这样复杂的话题上进行十次模拟显然不足以得出定论。为了增强影响力的论断,通过增加k(使用之前提到的统计方法)并对结果进行平均,重复多次模拟。通常,模拟的可能结果越多,应该运行的次数也越多。不过,这里也有收益递减的规律。你需要通过分别更新kn的值,来实验不同的模拟次数和时长。

我们将要检查的代码的最后部分是term_subgraph函数,它在列表 6-3 中被调用。列表 6-5 中的函数接受一个感兴趣的术语,并搜索底层数据以找到所有相关帖子。

def term_subgraph(term, df):
    dat_rows = df[df["text"].str.contains(term)]
    dat_replies = df[df["in_reply_to_id"].isin(dat_rows["id"].values)]
    hG = nx.DiGraph()
    for idx in dat_replies.index:
        row = dat_replies.loc[idx]
        hG.add_edge(row["in_reply_to_screen_name"], row["user_screen_name"])    
    return hG

列表 6-5: 基于术语定义子图

该函数接受我们感兴趣的搜索词和我们之前定义的post_df DataFrame对象。使用str.contains函数,我们将数据筛选为仅包含搜索词的文本列的行。然后,我们通过搜索in_reply_to_id列中的相关帖子 ID,收集这些帖子的回复,并将其存储在名为dat_repliesDataFrame中。接下来,我们定义一个DiGraph对象来保存生成的图数据,并将其存储在名为hG的变量中。我们遍历dat_replies的索引列表,对于每个条目,查找与该索引关联的行。我们使用该行的in_reply_to_screen_nameuser_screen_name来创建图中的边,展示在感兴趣话题上的影响方向。一旦循环完成,我们返回已完成的子图。

现在我们已经定义了所有基本代码,我们可以开始改进我们的简单模型。在下一部分,我们将介绍如何通过资源分配使我们的消息行为更加现实。

建模信息流

到目前为止,我们的模拟将消息视作一个从节点到节点移动的单一对象,就像一个包裹被送到一个地址一样。但如果消息可以同时传递给多个用户呢?我们的模型对于单个副本的消息是适用的,但如果我们能够找到一种方法,更直观地将信息流动通过网络建模就更好了。想想看:你不会只发送一个生日派对邀请函并要求每个被邀请者把消息传递给列表上的下一个人;你会将多个邀请函发送给你希望出席的人。每个被邀请者可能会邀请另一个人一同前往派对,这样消息就会同时沿着网络进一步传播。为了建模这种类型的信息流动,我们需要改进我们的状态机,使其处理多个副本的消息。

为了模拟多个 q 副本可以存在的情况,我们可以将消息传递重新表述为网络中资源流动的问题。通过这样做,我们可以弄清楚两个人在过去交流了多少信息,并用这个作为他们未来可能交流的一个指标。资源分配 (RA) 是一个最初提出用来描述机场连接性与旅行能力之间非线性关系的模型。^(7) 我们将使用相同的原理来量化信息交换的质量,随着消息在网络中的传播。

一般来说,RA 描述了两个节点 (u, v) 之间潜在的资源流动,其中 v 不是 u 的邻居,但它们通过一个有向路径相连 (vΓ[(][u][)] ∧ ρ(uv) ∈ E)。假设有一个有向图中的节点 u 拥有一个单位的资源,并将这些资源均匀分配给所有的直接邻居,那么分配给网络中任何成员的资源就是 uv 之间每条路径末端资源的总和:

你可以将这个值看作是在节点 u 的分配情况下,节点 v 的重要性。如果 |ρ(u, v)| > 2,那么这个过程会对所有节点进行重复,直到有一定数量的资源到达 v。因此,你可以将这个值看作是 u 通过分配网络提供给 v 的资源量。

作为一个具体的例子,假设你正在调查一个犯罪组织,该组织销售从伪造者那里购买的假冒商品。这个假设中的组织老板购买了 100 箱仿冒手袋(即S[0]的初始资源量)。然后,他将这些商品分配给他的四个高级手下,每人分得 25 箱。最后,每个手下将他们的 25 箱商品分配给他们街角的商店。如果每个手下与 5 个店面有联系,那么每个店面将收到 5 箱商品。如果犯罪老板失去其中一个店面,损失将仅占他库存的 5%。这是一个非常简化的模型,假设每个节点和路径可以均匀地传递相关资源。然而,实际情况并不总是如此。

从形式上讲,前面公式中的m06011部分被称为流函数,它模拟了传递或接收资源的特定行为类型。使用这个流函数,资源会均匀地分配给节点u的所有邻居,就像假冒商品的箱子一样。NetworkX 内置了几种不同的流函数。遗憾的是,它们并没有为这里定义的有向图实现。当你从研究转向应用时,你通常需要负责扩展代码库,填补像这样的缺失定义。graph_funcs.py文件包含了有向资源分配的代码,所以你可以使用它来进行实验。

通过将 HITS 算法的历史分析与资源分配的同步流结合起来,我们可以创建一个有价值的模型,能够基于之前的观察模拟用户行为。你应该能够在这个状态机和蒙特卡洛仿真框架上进行扩展,模拟各种有趣的现象,不仅仅是在社交网络中,而是涵盖整个信息安全主题。

在下一节中,我们将进入一个概念验证应用,它将带我们更深入地了解应用博弈论和蒙特卡洛仿真,通过模拟在我们的社交网络平台上的对抗性对决。准备好迎接挑战吧!

概念验证:干扰信息流

本章的最终问题——“可以切断哪些连接以破坏两个节点之间的信息流?”——是一个非常有趣的安全话题。有许多场景,其中破坏信息流向特定节点子集的流动可能会带来灾难性的后果。想象一下,一个医院依赖单一的电力来源。要切断医院的任何电源插座,你只需切断医院与电源之间的唯一连接。这是一个单点故障,为了避免这种情况,医院会部署多点连接到电网,并安装备用发电机以应对更严重的中断。许多家庭网络也存在这种设计缺陷。要切断路由器后面所有连接的设备,只需要切断路由器前端的连接。在社交网络中,就像在企业中一样,类似的故障点经常发生。公司常常有被称为“关键员工”^(8)的人,他们承担着其他员工无法胜任的角色,或拥有公司运营所需的深奥知识。关键员工启发了本章概念验证的证明:通过蒙特卡洛模拟来建模在一个发展中的社交网络中破坏信息流动的潜力。

在本章剩余部分,我们将构建一个模拟,假设我们的社交网络正受到一个邪恶外部敌人的攻击。我们将使用一些现代互联网诞生时所用的分析技术,看看破坏我们的社交网络会有多困难。有时候,扮演坏人也挺有趣的!

建模一个演化中的网络

在静态网络中,你可以找到一组边,当这些边被移除时,会将两个节点分开(你稍后将看到一种生成该列表的方法),但这并未考虑到网络的适应性,例如将另一名员工进行跨职能培训,让他掌握关键员工的深奥知识,从而缓解单点故障。为了模拟一个演化中的网络,我们将模拟一个两人轮流制的游戏场景,其中一名玩家试图将信息从起始用户传递到终端用户,而其对手则试图阻止该信息到达终端。为了使游戏更复杂,网络本身会在每一轮变化,因为用户会转发其他用户的信息或断开与曾经连接的人的联系。玩家 1 在网络及其所有用户中行动,而玩家 2 则充当对抗力量。玩家 1 的目标是将信息u从源节点(uA)发送到汇节点(v[Ω])。玩家 2 的目标是通过选择性地移除网络中的路径,阻止该信息到达汇节点。游戏分为三个阶段:网络适应、信息移动,最后是对抗性移动(按此顺序)。

如果q到达v[Ω],或者当没有路径可以完成传输时,游戏结束:

该方程可以转化为方便的辅助函数,见示例 6-6。

def check_win(G, uq, omega):
  ❶ if uq != omega and nx.has_path(G, uq, omega):
        return None
  ❷ elif uq == omega:
        return 1
  ❸ elif not nx.has_path(G, uq, omega):
        return -1

示例 6-6:检查终止条件

该函数接受图对象、当前持有消息的节点 ID 和目标节点的 ID。如果这两个节点不同且它们之间有路径 ❶,游戏没有结束,因此代码返回None(即没有赢家)。如果两个节点不相同且当前节点与目标节点之间没有路径 ❸,则玩家 2 已成功隔离消息,函数返回-1。如果这两个 ID 匹配 ❷,则消息已到达目标节点,函数返回1

通过网络传递消息

第二个辅助函数weighted_choice,如示例 6-7 所示,将用于加权随机选择下一个接收消息的节点。

def weighted_choice(scores):
    totals = []
    running_total = 0
  ❶ for w in scores.values():
        running_total += w
        totals.append(running_total)
  ❷ rnd = random() * running_total
    for i in range(len(totals)):
      ❸ if rnd <= totals[i]:
            key = list(scores.keys())[i]
            return key

示例 6-7:用于偏向行走的加权随机选择函数

输入参数scores是一个字典,格式为{item: weight},为每个可能被选择的项目分配一个权重。 (权重不需要加总为 1;只有值的相对大小才重要。)totals列表将实数空间在 0 和权重总和之间分割 (m06013),其分割的大小与所代表项目的权重成正比,通过将每个项目加到running_total中,并在每个项目添加后记录运行总和 ❶。所有权重的总和然后会缩放一个随机值 ❷,使其落入某个分区,而该分区决定了选择哪个项目 ❸。权重较大的项目映射到较大的分区,这意味着这些项目更有可能被选择,因此这就是“加权随机选择”。

以一个具体例子为例,输入字典为{"A":1,"B":2,"C":3}。在第一次循环执行后,totals列表包含[1,3,6],而running_total6。随机实数值rnd(介于 0 和 1 之间)通过random函数选择,然后乘以running_total ❷以产生随机选择的权重百分比。随机值1.0表示最大权重,在此情况下为:

我们可以通过计算分配给键的数轴空间来验证断点是否准确反映我们的输入权重,这个空间称为其key space。这些应等于 1 / 6 = 0.166,2 / 6 = 0.333 和 3 / 6 = 0.5,分别对应于键 ABC。我们通过将键的下选择边界减去其上选择边界来找到键空间。为了选择键 Arnd 必须小于或等于大约 0.166(0.166 × 6 = 0.996)。为了选择键 Brnd 需要介于 0.166 和 0.5 之间(0.5 × 6 = 3),这意味着键 B 的键空间为 (0.5 – 0.166 = 0.333)。我们可以将 B 的键空间除以 A 的键空间,以获得相对大小比较(0.333 / 0.166 = 2.006),这意味着键 B 的键空间是键 A 的两倍,正如我们要求的那样。最后,rnd 必须大于 0.5 且小于或等于 1.0 才能选择键 C。键 C 的键空间为 (1 – 0.5 = 0.5)。你可以继续这个键空间的逻辑,证明分配给 C 的空间是分配给 A 的三倍(0.5 / 0.166 = 3.0),并且是分配给 B 的键空间的 1.5 倍(0.5 / 0.333 = 1.5)。希望这有助于说明我们的输入字典中的值如何控制在随机选择过程中创建的键空间的大小。我们将在证明过程中大量依赖 weighted_choice 函数,因此花时间详细理解它是值得的。

测量信息流量

网络中某些节点之间的连接可能能够承载比其他连接更多的信息。例如,在社交网络中,某些成员可能更有效地传播信息,就像公司中的关键员工一样。在社交网络中,两个节点之间的信息流量并不是简单地可以测量的,它取决于你的研究问题。在我们的模拟游戏中,边的容量是指给定帖子中的字符数(最大 500 个字符,在写作时为止)。

通过向E中的边添加一个名为capacity的属性,该属性表示在单位时间内通过特定边传输的最大信息量,我们可以使用最大流,最小割定理来比较删除不同子集边对网络整体流量和电容的影响。^(9) 我们将在改进玩家 2 时深入了解这个定理,但目前只需知道,它使我们能够模拟一个资源在网络中扩散的过程,而不是像之前看到的那样从一个点移动到另一个点。

现在是时候退后一步,记住这一切对我们来说的重要性了。我们的最终目标是测试敌人破坏网络通信的难易程度。最大流、最小割定理为我们提供了测试两个节点是否仍能通信所需的信息(因为两个节点之间仍然有一条路径)。它还帮助我们确定哪些割对敌人来说更有利或不利,同时为敌人提供了一种快速判断其选项的方法。了解最大流、最小割理论的攻击者,可能比攻击随机通信通道的攻击者更有可能破坏网络。我们将在接下来的游戏中通过实现两个版本的敌人来检验这一假设,并比较他们能造成的破坏。

游戏如何运作

这款游戏其实非常简单。玩家 1(白帽)的目标是将消息从网络一侧的节点传送到网络另一侧的节点。为此,他们可以使用整个网络。在每回合中,他们会在网络中移动消息,试图到达接收节点。如果消息成功从源节点传输到接收节点,他们就赢了。虚拟桌子另一边是玩家 2(黑帽)。他们的任务是以任何代价阻止这个消息。在每回合中,他们将选择从网络中删除一条边。玩家 2 赢得胜利的条件是他们成功地切断网络,使得消息无法到达接收节点。

网络演变

社交网络很少有静态的拓扑结构:即使在你尝试测量它们时,链接和成员身份也在不断变化。网络适应阶段通过允许边的创建或删除来模拟拓扑的演变。这意味着新的路径可能会突然打开,旧的路径可能会自行消失。双方玩家都无法完全信任网络按预期运作。我选择将这一部分作为玩家 1 的回合来实现,因为在这种情况下他们是网络管理员。在每回合中,玩家 1 会从输入字母表中选择一个输入动作,针对每个没有持有消息的节点(∀u((¬)(q)^) ∈V wrs(Ξ),其中 wrs(Ξ) 是前面定义的加权随机选择函数)。Listing 6-8 显示了加权输入字母表,包括使用 connectdisconnect 来创建和解除边,或者像之前一样选择 pass

XI = {
    "connect": 2,
    "disconnect": 1,
    "pass": 2
}

Listing 6-8:没有消息的节点的加权输入

XI中的权重描述了网络随时间的趋势。这些值创造了一个网络随时间增长的情形,因为connectpass的每个单独权重为 40%(2 / 5 = 0.4),合计 80%的选择空间,而disconnect只有 20%。如果选择connect,节点将与另一个节点建立新边(即它收到来自另一个节点的回复),这意味着我们需要一种方法来选择它们连接的用户。我选择基于优先连接来实现这一点,优先连接的概念是,具有更多连接的用户比那些连接较少的用户更可能建立新连接。在我们的网络中,这意味着接收到来自许多其他用户回复的节点(具有较大的外度)更可能收到那些回复许多用户的用户的回复(具有较高的入度)。(即使当前节点不倾向于接收很多回复,它仍然更可能从一个更活跃的用户那里获得回复。)^(10)

严格来说,两个节点(uv)的无向优先连接(UPA)分数是它们邻居长度的乘积:

UPA( u, v ) = |Γ( u )| × |Γ( v )|

为了考虑我们网络图的方向性,我们可以使用有向优先连接(DPA)定义u的出邻居和v的入邻居:

DPA( u → v ) = |Γ( u → )| × |Γ( v ←)|

Listing 6-9 展示了加权随机连接函数,该函数将从 Listing 6-12 中显示的玩家 1 逻辑中调用。

def wrs_connect(G, u):
    scores = {}
  ❶ for i in range(len(G.nodes.keys())):
        v = list(G.nodes.keys())[i]
        if v == u:
            continue
      ❷ dpa_score = G.out_degree(u) * G.in_degree(v)      
        scores[v] = dpa_score
  ❸ return weighted_choice(scores) # Previously defined choice function

Listing 6-9: 一个有向优先连接加权随机选择

函数wrs_connect以图和连接的节点作为输入,并遍历图中的每个 ID ❶来计算输入节点与每个其他节点之间的 DPA 分数 ❷(跳过输入节点使用continue)。weighted_choice函数使用scores字典来选择一个节点进行连接 ❸:

vconn = wrs( [ DPA( u( ¬ q ), ¬ u )] ∀ ¬ u ∈ V )

如果选择disconnect,用户将与发送给他们最少信息的另一个用户断开联系(根据前面定义的容量来衡量)。G.in_edges中每个边的capacity属性在加权随机选择过程中用于选择一个邻居来断开联系。Listing 6-10 展示了计算给定节点的容量和选择权重的代码。

def ncap_weights(G, u):
    u_in = list(G.in_edges(u, data=True))
    n_capacity = {}
  ❶ for v,u,d in u_in:
        n_capacity[v] = d["capacity"]
  ❷ Q = 1 + max([itm[1] for itm in list(n_capacity.items())])
  ❸ n_weight = {k: (Q - n_capacity[k]) for k in n_capacity.keys()}
    return (n_capacity, n_weight)

Listing 6-10: 节点的容量加权

我们首先遍历节点的入边集合中的邻居数据 ❶,并将其收集到n_capacity字典中。如果我们没有提前将多个边合并(通过使用DiGraph而不是MultiDiGraph),我们需要先对每个入边的容量进行求和:

我们在选择过程中对容量较低的 N[capacity] 边赋予更高的权重,然后通过从修正值 Q = 1 + max(N[capacity] ) ❷ 中减去这些容量来反转容量,从而得到加权公式 m06016 ❸。

例如,如果 max(N[capacity]) = 10 ⇔ Q = 11,则带有 m06017 的边将获得权重 1 (m06018),而带有 m06019 的边将获得权重 10 (m06020)。

我们将为给定节点设置返回值为元组 (N[capacity] , N[weight] )。n_capacityn_weight 都是以邻接节点 ID 为键的字典。Listing 6-11 中的 wrs_disconnect 函数使用 n_weight 字典来选择最不重要的邻居来断开与 u 的连接。

def wrs_disconnect(G, u):
  ❶ u_in = list(G.in_edges(u))
    if len(u_in) < 1:
        return None
  ❷ caps, scores = ncap_weights(G, u)
    if scores is not None:
      ❸ return ext.weighted_choice(scores)

Listing 6-11: 一个加权随机断开函数

如果节点 u 没有入边(意味着它没有入度邻居可以断开连接),则函数返回 None ❶,结果与 pass 相同。如果找到多个边,则使用 Listing 6-10 中的函数 ❷ 计算所有入邻居的容量分数。此函数返回的键是要断开连接的邻居 (v[disconn] = wrs(N[weight] )) ❸。然后,在 Listing 6-12 中的玩家 1 逻辑中,边 (u((¬)(q)^) ← v[disconn] ) 被移除。

移动信息

在网络演化阶段之后,游戏进入信息传递阶段,此时唯一可能的输入是send。如果当前持有信息的节点 u(q) 与目标节点 v[Ω] 之间存在边,则信息沿着该边传递,玩家 1 获得胜利。否则,计算 u(q) 和 v[Ω] 之间的路径,并将信息沿这些路径中的一条传递到下一个节点,路径的选择是随机的。

Listing 6-12 中的代码处理玩家 1 的回合,包括网络适应和信息传递两个阶段。函数 player_one_turn 以图、持有信息的节点和目标节点作为参数,返回接收信息的节点以及图的更新状态。

def player_one_turn(G, uq, omega):
  ❶ if G.has_edge(uq, omega):
        return (omega, G)
    caps = [d["capacity"] for u,v,d in G.edges(data=True)]
  ❷ avg_cap = sum(caps) / len(caps)

  ❸ for u in list(G.nodes.keys()):
        if u == uq:
            try:
              ❹ paths = list(nx.all_shortest_paths(G, u, omega))
            except nx.exception.NetworkXNoPath:
                return (uq, G)
          ❺ path = choice(paths)
            pass_to = path[1]
        else:
          ❻ act = ext.weighted_choice(XI)
          ❼ if act == "pass":
                continue
          ❽ elif act == "connect":
                v_conn = wrs_connect(G, u)
                G.add_edge(u, v_conn, capacity=avg_cap)
          ❾ else:
                v_disconn = wrs_disconnect(G, u)
                if v_disconn is None:
                    continue
                G.remove_edge(v_disconn, u)
  ❿ return (pass_to, G)

Listing 6-12: 定义玩家 1 回合逻辑

如果 uq 和目标节点 omega 之间存在边 ❶,我们将信息传递给 omega。这将结束回合(并且游戏结束),玩家 1 获胜!否则,我们计算图的平均容量 ❷,该值将作为网络适应过程中添加的新边的容量。这使得平均值在每一回合之间变化,取决于上一个网络适应阶段是否删除了边(如果有的话)。

为了执行网络适应和消息传递,我们对图中的每个节点进行遍历 ❸。对于uq),我们尝试找到它与目标节点之间的有效路径 ❹(消息传递阶段)。如果此尝试失败并出现nx.exception.NetworkXNoPath,函数将返回,回合结束,玩家 2 获胜,因为消息无法到达目标节点。否则,我们使用choice函数随机选择一条路径 ❺,并将消息传递到路径中的第一个节点。

对于所有其他节点,我们使用加权随机函数和 Listing 6-8 ❻中定义的XI字典选择一个操作。如果选择pass,代码会使用continue关键字跳到下一个节点 ❼。如果返回connect,我们使用 Listing 6-9 中的wrs_connect函数形成一条新边 ❽。否则,选择了disconnect,我们使用 Listing 6-11 中的wrs_disconnect函数从图中移除一条边 ❾。最后,我们返回接收节点和更新后的图 ❿。

破坏网络

然后玩家 2 选择从网络中移除一条边,试图破坏消息的流动。蒙特卡罗仿真的一个优点是能够随着时间的推移比较不同的策略。为了说明这一点,我们将比较两种策略,看看玩家 2 如何实现他们的目标。在第一种策略中(Listing 6-13),玩家 2 从E中均匀地随机选择一条边:

def player_two_random(G):
  ❶ e = choice(list(G.edges()))
  ❷ G.remove_edge(*e)
  ❸ return G

Listing 6-13: 玩家 2 的随机实现

这将作为一个良好的基准,因为它最接近真正的随机行走。代码随机选择 ❶,然后移除 ❷ 图中的一条边;接着我们返回更新后的图 ❸。

这种策略的结果可以看作是一个零控制,就好像对手只是盲目地开始移除东西,完全不知道他们所影响的内容。在我们使用这种简单策略演示仿真并收集基准网络性能之后,我们将查看第二种策略,其中玩家 2 根据网络的流动信息选择其操作,以造成最大的破坏。

一旦玩家 2 完成选择破坏的边,回合就结束了。如果没有玩家获胜,下一回合开始,游戏继续进行,直到其中一位玩家成功达到目标。

在下一节中,我们将介绍如何选择起始节点和结束节点。在更大的网络中(比如你在实际应用中可能会遇到的网络),拥有自动化任务的方法,例如寻找数据,将在运行你的第一个仿真之前节省大量的手动探索时间。

游戏目标

在将所有这些内容结合成一个功能完整的仿真之前,让我们先来看一下shortest_path_scores辅助函数,见 Listing 6-14,它返回所有不直接连接的节点对的平均路径长度列表。

def shortest_path_scores(G):
    pairs = []
  ❶ for u, v in nx.non_edges(G):
        if u == v:
            continue
        if not nx.has_path(G, u, v):
            continue
 ❷ uv_paths = list(nx.all_shortest_paths(G, u, v))
      ❸ avg_len = sum([len(p) for p in uv_paths]) / len(uv_paths)
        pairs.append(((u, v), len(uv_paths), avg_len))
    sorted_scores = sorted(
        pairs,
      ❹ key=lambda kv: (kv[2], kv[1], kv[0]),
        reverse=True
    )
    return sorted_scores

Listing 6-14: 为加权选择创建平均长度得分

for循环❶调用 NetworkX 函数nx.non_edges获取所有可能的节点组合,这些组合没有直接连接的边,检查两个节点 uv 是否不同,并且检查它们之间是否存在一条或多条路径。如果任一条件不满足,我们将跳过该节点对,使用continue关键字。否则,我们使用nx.all_shortest_paths函数列出游戏开始时源节点和汇节点之间的所有潜在路径❷,然后计算平均路径长度❸,并将其附加到pairs列表中。一旦所有节点对处理完成,我们根据平均路径长度对结果进行降序排序,若平均路径长度相同,则根据节点 u 的 ID 排序,最后按节点 v 的 ID 排序❹。

在清单 6-15 中,我们将这些得分与weighted_choice函数结合,随机选择源节点和汇节点对,同时偏向那些具有更多路径或较长路径的节点对,而不是具有较少或较短路径的节点对。我选择这种方法是为了确保模拟有足够的路线来保持游戏的趣味性。你可以根据模拟中的其他参数选择源节点和汇节点,甚至可以扩展模拟,测试所有可能的源节点和汇节点组合。

游戏模拟

最后,到了将所有这些函数结合成一个完整游戏的时候,代码见清单 6-15。我们将运行游戏模拟 25 次,每次使用一对不同的源节点和汇节点。每次运行将生成 k 次随机游走,每次游走代表玩家 1 和玩家 2 之间的一局游戏,并统计每个玩家的获胜次数。k 次得分的平均值即为该轮游戏的总得分。使用不同的源节点和汇节点,而不是一遍又一遍地运行相同的场景,将使我们对整个网络有更好的理解。代码清单 6-15 假设你已经构建了图(使用类似清单 6-3 的代码)。

path_scores = shortest_path_scores(G)
k = 10 # Number of random walks
n = 10 # Number of steps in each walk
❶ path_weights = {(p[0][0], p[0][1]): p[2] for p in path_scores}
played = []
for r in range(25): # Run the simulation 25 times
  ❷ selected = weighted_choice(path_weights)
    while selected in played:
        selected = weighted_choice(path_weights) # Avoid repeated selection
 played.append(selected)    # Track new pair
    alpha = selected[0]        # Source node
    omega = selected[1]        # Sink node
    game_res = []              # Results from k random walks
  ❸ for i in range(k):         # Perform k random walk simulations
        newG = G.copy()        # Copy the graph to maintain the original state
        now_at = alpha
      ❹ for j in range(n):     # Perform at most n steps in each walk
          ❺ w = check_win(newG, now_at, omega)
            if w is not None:
                game_res.append(w)
                break
          ❻ now_at, newG = player_one_turn(newG, now_at, omega)
          ❼ if not check_win(newG, now_at, omega):
              ❽ newG = player_two_random(newG)
  ❾ tally = sum(game_res)
    avg = tally / len(game_res)
    print("\t Average %.4f" % avg)

清单 6-15:主要的游戏模拟函数

在运行模拟组之前,我们首先使用shortest_path_scores(清单 6-14)❶获取节点之间的平均路径长度列表,接着将返回的平均路径长度转换为path_weights列表(即平均路径长度较长的节点获得更高的权重),然后选择一对节点(该对节点即为path_weights返回的键值)❷。如果该对节点及其相关路径已经在模拟中使用过(由played列表追踪),我们将选择另一个节点对。从选中的节点对及路径中,我们确定源节点和汇节点,即alphaomega

一旦我们找到了有效的源节点和汇节点对,就执行k次随机游走。for循环的每次迭代❸构成一次完整的游戏,在图的副本(newG)上进行,以保持比赛之间的原始拓扑结构。每次n步随机游走❹为两位玩家生成最多n轮,并在每个阶段 57 检查胜利条件。每局游戏的结果都会添加到game_res中。这个循环的每次迭代都算作游戏中的一次完整回合。

一旦k次游走完成,我们通过求和game_res来统计胜利(玩家 1 胜利得 1 分,玩家 2 胜利得–1 分)❾,然后取平均值tally作为k次游走的总得分。

运行 Listing 6-15 中的代码以查看 25 次模拟的结果。每次测试(最外层的for循环)产生的平均值可能会有很大差异,正如你从这个代码片段中可以看到的那样:

Average 0.7600
Average 0.2800
Average 0.6000
Average 0.1200
Average -0.5200
Average 0.6800
Average -0.7600
`--snip--`
Average 0.9200
Average 0.8400
Average -0.3600
Average 0.5200
Average 0.6000
Average 0.2000
Average -0.5200

0.0的得分意味着两位玩家赢得的比赛数相同。正数平均值表示玩家 1 比玩家 2 赢得更多。这个值越接近+1,比赛就越倾向于玩家 1。相反,当得分低于 0 时,情况则相反。-1的平均得分表示玩家 2 赢得了每一场比赛。

最后一步是总结所有测试的结果。我们可以通过将各个平均值相加,然后除以测试的数量来完成。我们将其称为总体均值。总体均值的好处有两个。首先,它将所有测试总结为一个可以解读的数字,而不是一系列测试结果。其次,总体均值应该相对稳定,相较于个别运行结果中观察到的值。如果我们重新运行代码,可能会得到不同的单个测试结果。不过,总体均值应该保持相对稳定。

在分析模型三次时,我得到了总体平均值 0.2160、0.2320 和 0.1808。当然,在统计学中我们会处理不确定性,因此一个更好的总体均值衡量标准是我们认为实际总体均值将落在的数字范围,这取决于我们期望的置信度水平。为了做到这一点,我们使用scipy.stats.t.interval函数,传入我们的模拟结果和期望的置信区间(称为alpha 参数),并将其作为浮动值传入。结果是一个元组,包含我们预测实际总体均值会落在其中的上下限。例如,我运行了 6,250 次模拟,我可以有 95%的信心认为当前配置下的模拟的真实总体均值将在 0.1078 和 0.2225 之间,这意味着玩家 1 有轻微的优势。当前设计似乎稍微偏向防守方,因为其增长和对手缺乏智能。

现在我们已经为我们的网络建立了基准性能,让我们看看是否可以通过让对手观察网络并选择要切断的路线来提高他们的机会。然后我们可以比较两个模拟的结果(以预测的总体均值为标准),看看我们的改变是否显著影响了玩家 2 破坏网络的机会。

对玩家 2 的改进

让我们看看如果给玩家 2 更多的智能会发生什么。在这个版本中,玩家 2 使用更新后的图和当前消息位置,移除一条对消息位置和目标之间路径重要的边(这对人类玩家来说是一个相对直观的策略)。

为了将这一策略编码化,玩家 2 将使用最大流最小割定理。最大流最小割分析是现代 TCP/IP 网络创建的推动力之一。该协议将消息分割成称为 数据包 的小块,然后根据响应时间和承载能力为不同部分的传输选择不同的路线。该设计的基本思想是,必须拆除网络中很大一部分,才能将两个远程节点相互断开。需要移除的节点列表被称为 割集。我们模拟游戏中的对手将利用割集信息执行保罗·巴兰(Paul Baran)在其研究中关注的那种攻击(www.rand.org/about/history/baran.list.html)——也就是选择性地针对并移除通信通道以破坏网络。

简而言之,最大流最小割定理告诉我们两个关键信息。首先,最大流部分描述了在两个节点(uv)之间所有路径上可以流动的最大资源量。最小割部分描述了从网络中移除最少数量的边,以切断两个节点之间的所有路径。更正式地说:给定两个节点(uv),最大流最小割定理告诉你需要移除的最少边集合的总容量,以使得两个节点之间没有路径(割集)。一个割是一个图的划分 GST),使得 uS 中,vT 中。

最大流最小割定理中定义的 容量约束 限制了每条边在每个模拟步骤中的流量,流量必须小于或等于该边的最大容量:

(u → v)流量 ≤ (u → v)容量

定理的 保守约束 说明流入每个节点的量等于流出该节点的量:

再次简化来说:每个节点都会发送它收到的所有资源;它不会为自己保留任何资源。这个约束适用于所有节点,除了 uα 和 v[Ω]。就我们的模拟而言,这意味着任何转发消息的用户都能接收到该帖子包含的所有信息,而如果有人从他们那里转发消息,那个人也会接收到相同的信息量。源节点和汇节点由于它们在流中的位置,需特殊处理。源节点就像一个水龙头,能够向网络添加一定量的资源,因此没有资源流入源节点,只有流出。在我们的图中,这与一个可能收到大量转发但不太可能自己转发的人(具有高出度和低入度的节点)同义。相反,汇节点就像一个海绵,吸收从网络传来的信息而不传递出去。任何到达汇节点的信息都会被吸收。

列表 6-16 中的 nx.minimum_cut 函数使用最大流最小割定理来确定两个节点(uv)之间的最小割值,以及由割产生的分割,结果以元组(cut_valuepartition)的形式返回。partition 是一个元组(reachableunreachable)表示从 u 可以到达和不能到达的节点。回顾之前的定义,割将图划分为两个部分,如果移除 cutset,这两个节点就会在不同的、断开的组件中。

def player_two_turn(G, uq, omega):
  ❶ cut_value, partition = nx.minimum_cut(G, uq, omega)
    reachable, unreachable = partition
    cutset = set()
  ❷ for u, nbrs in ((n, G[n]) for n in reachable):
        cutset.update((u, v) for v in nbrs if v in unreachable)
  ❸ if len(cutset) >= 2:
        cut = choice(list(cutset))
        G.remove_edge(*cut)
  ❹ elif len(cutset) == 1:
        cut = list(cutset)[0]
        G.remove_edge(*cut)
    return G

列表 6-16:通过增加智能来更新玩家 2

我们首先计算 uq)和 v[Ω] 之间的 cutset ❶。为了将 partition 元组转换为 cutset,我们遍历 reachable 集合中的邻居对 ❷。对于集合中的每个节点,我们遍历它们在图中的所有邻居。如果它们的邻居在 unreachable 集合中找到,那么 reachable 集合中的节点与 unreachable 集合中的节点之间的边属于 cutset。当我们通过这种方式处理完所有节点后,就能得到所有断开两个节点所需的边。如果集合中只有一条边,玩家 2 选择这条边进行移除 ❹。否则,玩家 2 从 cutset 中随机选择一条边,同样依赖于 choice 函数 ❸。

这个输出展示了重新运行模拟并采用新策略对玩家 2 的结果:

Average -0.9200
Average -1.0000
Average -1.0000
Average -1.0000
Average -0.6000
Average -0.8400
Average -1.0000
`--snip--`
Average -1.0000
Average -0.9200
Average -0.9200
Average -1.0000
Average -1.0000
Average -0.3600
Average -0.9200

在分析了修改后的玩家 2 在 6,250 次模拟中的表现后,我得到的总体均值为 -0.8144,并且可以 95% 确信,修改后的玩家 2 在模拟中的总体均值介于 -0.9484 和 -0.6803 之间。当你在自己的机器上运行代码时,可能会看到稍有不同的结果(记住,我们在处理很多随机性),但整体趋势应该保持一致。看起来这个简单的策略将模拟的结果大大倾向于玩家 2,即使网络增长仍然倾向于玩家 1。

我们总是有可能错误地声称我们改善了玩家 2 的获胜机会。由于我们无法模拟每个可能的结果,我们永远无法百分之百确定我们的总体均值是否准确。那么,我们如何确保这个结果不是某种随机偶然现象呢?事实是,我们只能在一定范围内确保这一点。我们必须接受我们无法确切知道的现实。这就提出了一个重要问题:我们需要思考我们愿意接受多少风险,以避免得出错误结论。当你在实际环境中进行分析时,错误结论的后果往往是现实的。你应该选择一个与风险相匹配的置信水平,以防万一你错了。风险越高,你需要的置信度就应该越高。一旦你选择了期望的置信水平,你可以通过从 100 减去所需的置信水平来将其转换为 t 检验的阈值。例如,我希望非常确定我们的结果不是偶然的,因此我将置信水平设置为 99%,这意味着我们愿意接受 1%的概率得出错误结论。我们现在可以使用这个阈值来检验我们是否提高了玩家 2 的获胜机会。

更正式地,我们可以陈述假设,改变玩家 2 的逻辑显著降低了总体均值(h[1] = μ[0] > μ[1])。那么零假设是,随机样本的均值将等于或小于修改后的玩家均值(h[1] = μ[0] ≥ μ[1])。我们可以使用一种叫做双样本 t 检验的统计方法来比较这组结果的总体均值。这个 t 检验量化了两个样本的算术均值之间的差异。一个常见的应用是检验一个新过程或治疗方法是否优于现有的过程或治疗方法。在我们的案例中,我们将使用它来确定两个总体均值之间的差异是否足够显著,以声称我们为玩家 2 所做的改变确实提高了他们的获胜机会。

该概念验证使用了scipy.stats.ttest_ind函数来运行此检验。结果是一个具有名为pvalue属性的对象。p 值量化了在零假设为真的情况下,观察到与测试值相同或更极端值的概率。我们将这个数字与 1%的阈值进行比较,以确定我们是否对结果有足够的信心来拒绝零假设。在这种情况下,我已运行了十几次测试,每次玩家 2 的得分显著低于支持我们 99%确定的结论,即我们对玩家 2 的改变提高了他们的获胜机会。我们可以如图 6-4 所示,直观地展示两个概率分布,以了解这一变化带来了多大的影响。

图 6-4:比较概率分布

这个图表展示了随机玩家模型和改进后的玩家 2 模型的所有可能测试结果的概率。浅灰色虚线代表随机玩家模型的基准表现。深灰色实线则是改进后的玩家模型的表现。大峰值和急剧下降出现在–0.8 附近,表明改进后的玩家表现更为一致,并且可以以较大差距赢得大部分比赛。事实上,使用改进后的玩家 2 代码,任何一系列测试的平均值达到–0.25 的可能性极低。我们可以将其解释为,选择性地移除边缘有潜力大幅干扰该网络中的信息流动。

你可以使用命令python mcs_multiplayer.py在书籍附带材料的第六章目录中运行概念验证。每次执行时,代码会运行一组针对两种玩家类型的模拟,然后计算人口均值并通过单尾 t 检验进行比较。它将输出一行,告诉我们是否可以拒绝原假设,最后它会生成一个类似于图 6-4 的图表进行分析。作为一个练习,尝试调整XI中的权重,更多地偏向新连接,看看这是否会影响结果并对玩家 1 有利。你还能对玩家 1 做哪些其他改动,以提高他们防御网络的能力?

总结

本章介绍的概念——蒙特卡罗模拟、有限状态机、随机游走、加权选择——结合过去三章的基础图论,构成了一套极其灵活的工具,远远超出了社交网络分析的范畴。通过为模拟定义有限状态机、分析重复模拟以确定特定结果的可能性、修改模拟以获得不同的结果和见解,以及建模图形如何随着时间演变,你可以通过模拟环境潜在变化来定量评估安全风险。

我在群体流动动态中经常应用蒙特卡罗模拟。预测人们如何在区域内移动,他们会在哪里聚集,以及他们如何响应不同类型的障碍物而改变这些移动方式,是规划有效物理安全控制的关键之一。我们将在第三部分的艺术画廊问题中进一步讨论这一点,但你或许已经有了一些如何利用我们所学内容来解决这一任务的想法。

然而,这仅仅是蒙特卡罗模拟的开始。通过改变每个模拟步骤中的逻辑,你可以模拟网络中的各种独特行为。设计一个合适的模拟既是一门艺术,也是一门科学,所以不要害怕拓展思路,探索一些大胆的模拟想法。

为了帮助你继续前进,本章附带的 Jupyter 笔记本包含了用于显示二维和三维随机游走的代码,你可以使用这些代码来可视化你开发的模拟。通常,看到结果的可视化分布可以带来有趣的发现(例如,路径总是穿过某一个点)。通过将随机游走显示代码与补充材料中的动画代码结合,你甚至可以创建模拟的视频。

在你探索相关文献时,你会发现关于如何在蒙特卡罗模拟中选择“最佳移动”的众多高级讨论。正如你在概念验证中所看到的,参数的微小变化可能对结果产生重大影响。了解每个模型变化的原因和影响非常重要,这样你才能制定更准确的评估,并对你模拟的网络得出有根据的结论。

你可以通过斯坦福大学的在线课程了解更多关于 GGP 理论和算法的内容(ggp.stanford.edu)。这些模型中的几个非常适用于各种信息安全任务,如风险分析、预算规划和事件响应。如果你想了解更多关于信息流的内容,可以查看研究论文《小组中的冲突与分裂的信息流模型》,^(11) 该文描述了一个用于测量信息流并检测社交网络中情绪不平衡的正式过程。**

第九章:使用几何改善安全实践

当你想到现代安全性时,几何学可能不会立刻浮现在脑海里。然而,利用计算机算法解决经典的几何问题,如线段长度、形状面积和物体交集,可以帮助你分析空间关系,而这些分析反过来可以指导你的安全实践。这个领域被称为计算几何

在接下来的几章中,我们将把几何应用于将数据与物理世界联系起来的问题,并利用几何规则。在第八章中,我们将利用形状的特性,通过你可能听说过的过程——位置三角测量,使用手机数据和 MapBox 地图定位一个假想的犯罪嫌疑人。然后,在第九章中,我们将戴上资源规划的帽子,使用我最喜欢的几何算法之一——Voronoi 镶嵌,来看紧急服务(如消防站)的分布。最后,在第十章中,我们将探索使用几何进行人脸识别。

但首先,我们需要更一般地讲解计算几何。我们将从本章开始,通过 Python 示例讲解基本理论。我们将使用一个叫做 Shapely 的 Python 库,它可以抽象化很多常见的任务,例如定义形状和检查两个形状是否在任何点上接触或重叠。

描述形状

几何是数学中相对直观的领域之一。如果我给你展示一个形状并让你标出它是正方形、三角形还是圆形,你甚至不需要刻意思考形状的定义;你会“知道”对应的名称。将几何直觉编码到计算机中却并非易事。因此,在我们讨论如何分析形状之前,我们需要一种方式,将形状描述为程序能够理解的数据格式。接下来,我们需要为每种我们希望能够识别的形状定义一系列数学检查。幸运的是,大部分繁重的工作已经通过 Python 的 Shapely 库为我们完成了。在本节中,我们将使用 Shapely 定义一些我们需要理解的关键形状,以便后续的项目能够理解。我们将从形状的基本构建块开始:点和线。从这里开始,我们可以构建越来越复杂的形状表示,组合形状形成 2D 模型,并查看 Shapely 提供的一些有趣分析功能。

点与线

我们从一个空的宇宙开始,表示为二维笛卡尔平面。这个世界中只有两种类型的对象:一个点和一个线段。表示平面上的一个精确位置,使用常见的 (x, y) 坐标系统。线段是无限长线的一部分;它由两个不同的端点界定,并包含线段之间的每个点。理论上,在两个端点之间有无限多个点;但在实际应用中, distinct points 的数量受到平台或编程语言浮动点精度的限制。Python 支持 17 位小数精度,这对于我们的任务来说已经足够了。

在 Shapely 中,你通过告诉库点的 xy 值来定义一个点,参见 Listing 7-1。

from shapely.geometry import Point
point_a = Point(2.0, 4.0)
point_b = Point(0.0, 0.0)

Listing 7-1: 在 Shapely 中定义一个点

这段代码在我们的笛卡尔平面上创建了两个 Point 对象:point_a 在 (2.0, 4.0) 处,point_b 在 (0.0, 0.0) 处。

要创建一个线段,我们可以调用 Shapely 中的 LineString 类,并传入起始点 (x, y) 和终点 (x, y)。我们可以传入元组或 Point 对象。Listing 7-2 显示了如何从头开始或从之前创建的两个点创建一个 LineString 对象。

from shapely.geometry import LineString
line = LineString([(2.0, 4.0), (0.0, 0.0)])
line2 = LineString([point_a, point_b])

Listing 7-2: 从 Point 对象创建 LineString

在 Listing 7-2 中定义的两个线段从库的角度来看是相同的,因此你可以自由选择适合你代码的语法。如果你打算在整个应用中重复使用相同的点,首先创建 Point 对象会使代码更简洁、易懂。

点和线段是形状的最基本构建块。让我们来看一下如何将它们组合起来定义更复杂的形状,称为多边形。

多边形

通过以各种配置组合点和线段,我们可以构建多边形,如正方形、星形,甚至是非常接近的圆形近似图形。在几何学中,多边形 P 是由有限数量的点构成的平面图形,这些点通过线段连接,形成一个闭合链条。闭合链条意味着序列中的第一个点和最后一个点始终相同,因此这组线段总是通过连接回原点来结束。构建 P 时使用的线段可以互换地称为 。沿着 P 边界的点通常称为该形状的 顶点(单数 顶点)。Listing 7-3 显示了如何使用 Shapely 库创建一个多边形。

from shapely.geometry import Polygon
poly_a = Polygon([(0, 0), (10, 0), (7, 5), (3, 5)])
poly_b = Polygon([point_a, point_b, (10, 0), point_a])

Listing 7-3: 从 Point 对象创建 Polygon

首先,我们导入Polygon类,它允许我们像在列表 7-2 中定义LineString对象一样定义多边形。与Polygon类的主要区别在于,Shapely 会在没有显式定义形状边界的情况下自动创建一个额外的点来闭合形状(如poly_a)。第二个对象poly_b展示了如何在定义多边形周长时混合Point对象和硬编码值。它还演示了如何通过包含一个最终与初始点相匹配的点来显式闭合多边形。记住:尽可能显式定义比隐式定义更好。

规则多边形是所有角度和边长都相等的多边形。任何不满足此条件的多边形(大多数多边形)都被认为是不规则的。简单多边形只有一个边界,且在任何点都不会与自身相交。复杂多边形有一个或多个相交的边,使得形状扭曲自我。许多关于简单多边形的规则不适用于复杂多边形,因此我们将避免使用它们。

多边形可以是非常复杂的形状,具有数百条边和顶点,但所有可能的多边形都可以分为两类之一。多边形是指所有内部角度都小于 180 度的多边形。多边形的所有顶点都将指向外部,远离形状的内部。多边形是指任何不符合凸形定义的多边形。一个简单的经验法则是,如果你可以仅使用左转逆时针绕着整个形状的周长行走,那么形状是凸的。如果你必须右转才能到达下一个顶点,那么形状是凹的。图 7-1 展示了它们的区别。

图 7-1:凹形与凸形的区别

左边的形状有两个点,周长向内部移动(1, 1)和(0.8, 1)。如果你逆时针沿着这个形状的周长行走,当你到达这两个点中的任何一个时,你都必须右转。右边的形状没有任何指向内部的顶点。你确实可以逆时针绕着整个形状行走,只需要左转,所以它是凸形的。

类似于你可以通过组合点和线段来创建基本形状,你也可以通过组合简单多边形和一种叫做线环多边形(简称)来在内部创建空洞。图 7-2 展示了将简单多边形与环组合的结果。

图 7-2:比较多边形类型

一个环形多边形rph(图 7-2 中的最左侧图像)是一个空心形状,没有实心区域。它完全由沿其定义边界的点和线段组成,即图像中的粗黑色轮廓线。

一个简单的多边形P(中图)是一个实心平面区域,包含其边界内的所有点以及沿着定义周长的所有点,如中间的黑色形状所示。

带孔的多边形(右图)将左侧的环形多边形与中间的简单多边形结合起来。为了将两者合并,我们从P的点集中减去环形中的点(P2 = P1 ∉ rph)。落在环形内的点会被排除在整体多边形的几何形状之外。

列表 7-4 展示了我们如何在图中构建三种不同类别的多边形。

from shapely.geometry import Polygon, LinearRing
poly_a = Polygon([(0, 0), (10, 0), (7, 5), (3, 5)])
poly_hole = LinearRing([(2, 2), (8, 2), (5, 3), (4, 3)])
rbus_b_holed = Polygon(poly_a, [poly_hole])

列表 7-4:在 Shapely 中创建带孔的多边形

我们定义了主要的多边形poly_a,与列表 7-3 中一样,使用其边界顶点来定义。为了定义环形多边形poly_hole,我们创建了一个LinearRing对象,通过传入定义其周长的顶点。我们通过将这两种形状合并成一个新的Polygon对象来创建最终的多边形。poly_hole对象被放入一个列表中,以支持在同一个多边形中使用多个孔。孔不能相互交叉,也不能与多边形的外部交叉;它们只能在一个点上相接。Shapely 并不会阻止你创建无效的特征,但当你尝试对其进行操作时,它会抛出异常。

通过结合单个点、线段、多边形和孔,我们可以开始以足够的精度模拟周围的物理空间,以便它能发挥作用。我们可以使用复杂的多边形来定义空间的边界,并在复杂多边形内部使用孔来表示由于障碍物人们无法通行的区域。我们将在“场景:规划音乐会的安全”部分深入讨论这一点,但首先,让我们先了解一些最佳实践,帮助您的代码顺利运行。

顶点顺序

当你创建一个形状时,顶点的顺序很重要。标准做法是按逆时针顺序(在 Shapely 中为ccw)传递顶点,围绕形状的周长。确保点按已知顺序排列可以大大加快一些操作的计算速度——例如检查某个点是否在形状内或多边形是否为凸形。Shapely 提供了一些函数来帮助处理这一点。在列表 7-5 中,我们检查一个对象是否为逆时针方向,如果不是,则转换它的点顺序,使其变为逆时针方向。

ring = LinearRing([(0,0), (1,1), (1,0)])
print(ring.is_ccw)
new_ring = LinearRing(list(ring.coords)[::-1])
print(new_ring.is_ccw)

列表 7-5:检查和修复顶点顺序

我们从创建一个LinearRing对象开始。你可以在一张坐标纸上验证,顶点的顺序从左到右,形成一个顺时针方向的三角形描述。我们可以使用 Shapely 通过打印布尔属性is_ccw来检查顶点是否按逆时针顺序排列,结果将为False。为了将列表转换为逆时针顺序,我们使用 Python 的列表反转([::-1]),它会将坐标按相反顺序添加到一个新列表中,然后将重新排序后的列表赋给一个名为new_ring的新LinearRing对象。打印new_ring上的is_ccw将返回True,确认新的顺序确实是逆时针的。

现在你可以以计算机能够处理的方式描述形状,让我们来看看一些常见的操作,这些操作在几何分析安全问题时会反复使用。在下一节中,我们将介绍一些有用的算法,用于计算面积、确定重叠和交集,以及计算不规则形状的周长。

场景:为音乐会规划安全措施

了解计算几何理论的最佳方法是将其应用于场景中,所以假设你被要求为在当地公园举办的音乐会规划安全措施。你需要决定为活动分配多少安保人员,并确定他们的站位,同时向活动协调员提供安全的参会人数建议。图 7-3 展示了从上空视角看公园的布局。

图 7-3:带孔的公园多边形

多边形的轮廓代表了围绕公园的高围栏。公园底部附近的小方块是一个信息亭,参会者无法进入,因此我们从可用区域中减去它。公园顶部附近的矩形是舞台,参会者也无法进入,因此我们也将其移除。剩余的灰色区域是参会者可以进入的公园部分。我们将计算这个区域的面积,然后用结果来确定可以安全舒适地参加活动的人数。

计算安全容纳人数限制

要计算像公园这样的不规则形状的面积,首先我们必须将该形状分解为一组简单形状,如三角形,然后将每个组合形状的面积相加。将形状分解的过程称为镶嵌,涉及使用一个或多个几何形状,称为瓦片,在平面上进行覆盖,要求没有重叠和空隙。图 7-4 展示了两种为公园空间进行三角形镶嵌的方法。

图 7-4:简单多边形的三角形镶嵌和带孔多边形

左边的简单方法对基础多边形进行镶嵌,并暂时忽略孔洞。你可以计算出这个形状的总面积,然后减去两个限制区域的面积。我在早期使用手动方法做过这种计算。右边的方法则使用 Shapely 的 triangulate 函数对包含孔洞的复杂形状进行镶嵌(不要与我们在第八章中将要讨论的位置三角测量混淆)。我们可以通过将每个灰色三角形的 n 个面积相加来找到总面积:

这里,P[Δ[][i][]]BP[Δ[][i][]]H 分别是第 i 个三角形的底边和高度测量值,位于 P[Δ] 中。

幸运的是,我们不需要担心手动镶嵌,只为找出复杂形状的面积。Shapely 库会在你创建形状时自动处理所有这些工作。列表 7-6 展示了如何创建公园形状并计算参与者可用的面积。

park = Polygon([(0,0), (4.5,0.5), (9,3), (14,7), (12,9), (5,9)])
info_booth = LinearRing([(4,2), (5,2), (5,3), (4,3)])
stage = LinearRing([(6,7), (9,7), (9,8), (6,8)])
event_shape = Polygon(list(park.exterior.coords), [info_booth, stage])
event_area = event_shape.area*10
print (f"{event_area} m² usable area")

列表 7-6:创建复杂的公园形状并计算面积

我们首先创建最外层的 Polygon 来表示活动空间的边界。然后,我们为舞台和信息亭分别创建一个 LinearRing 对象。接下来,我们创建一个表示场地可用空间形状的复杂多边形。该库为多边形提供了一个 area 属性,该属性会尊重我们在创建时传入的任何孔洞。

当你处理像公园这样非常大的形状时,常见的做法是应用一个缩放因子——也就是说,将整个形状按某个已知常数缩小,以使得数字更易于处理。在图 7-3 和 7-4 中,1 个单位等于 10 米,或者说缩放因子是 0.1,因此通过将 event_shape.area 乘以 10,可以调整结果以适应地图的缩放。print 语句的输出是:

635.00 m² usable area

现在我们知道了可用面积,可以计算出能够安全容纳的参与者人数。列表 7-7 展示了如何通过将可用面积除以每个参与者所需的空间来转换为参与者人数。

import math
safe_capacity = int(math.floor(event_area / 0.75)) # 8ft sq
max_capacity = int(math.floor(event_area / 0.37))  # 4ft sq
print("Comfortable capacity: %d people" % safe_capacity)
print("Maximum safe capacity: %d people" % max_capacity)

列表 7-7:基于可用面积计算容纳能力

多年前,我为包含坐席和站立的与会者混合的活动定下了每人 0.75 平方米(大约 8 平方英尺)的标准面积。(后来我发现其他人对类似场景得出了大致相同的数字。)我们将event_area乘以 0.75,以找到可以舒适容纳与会者的最大人数,同时为一些人留出坐在毯子上的空间,而另一些人可以四处走动。为了找到一个区域内可以安全站立的最大人数,我们可以将间距大致减半,变为每人 0.37 平方米,约为 4 平方英尺。现在,我们可以将活动区域乘以 0.37,来找出场地的“站立区”最大容量max_capacity。这种紧密的间距就像站在一个拥挤的走廊里:你几乎要碰到其他人了,但还没有。代码的输出应为:

Comfortable capacity: 846 people
Maximum safe capacity: 1716 people

为了估算活动所需的安保人员数量,我们将 846 名与会者四舍五入为 900 人。根据经验,我使用一个 60:1 的比例,即每 60 名与会者配备 1 名安保人员,在这种情况下,900 / 60 = 15。 但是,900 人包括所有将进入活动区域的人,包括安保人员,所以在实际情况下,你实际上需要反向四舍五入,建议的最大舒适容量为 830 名与会者,同时根据更高的观众数量(900 人)估算安保人员数量。

在将这个公式应用于一些实际场地之后,你会发现你的舒适人数通常低于消防部门安全规范推荐的人数,这是故意的。消防部门的数字关注的是在紧急情况下能安全疏散的人数,与人群安保或舒适性无关。你还会发现活动协调员通常会选择你建议的数字和消防部门允许的数字之间的某个数值(因此在规划人员时向上四舍五入,而在推荐与会人数时向下四舍五入)。

确定安保人员的部署位置

现在我们知道了需要多少安保人员,接下来我们来确定他们的部署位置。我们将使用镶嵌技术,并结合另一种常见的操作——质心定位,它找到一个与多边形所有边界顶点等距的点(即质心)。一个凸形物体的质心总是位于物体区域内部。一个凹形物体的质心可能位于物体外部,但由于我们首先进行的是镶嵌,每个生成的三角形都是凸形的,因此质心将位于边界内。

P的质心 = [(x0, y0),(x1, y1),……,(xn – 1, yn – 1)] 是点 C(xy),其中

AP的有符号面积,通过鞋带算法计算得出:

P中的点需要按顺序沿着周边排列才能使该方法有效。如果顶点的顺序是逆时针方向,面积将为负值;否则,面积为正值。在任一情况下,C[(][x,y][)]的绝对值都是正确的。我们可以利用这一信息,将每个警卫部署在还未分配警卫的、面积最大的三角形的质心位置。清单 7-8 展示了如何实现这一点。

from shapely.ops import triangulate
tess = triangulate(event_shape)
area_dict = {i: tess[i].area for i in range(len(tess))}
sort_areas = sorted(area_dict.items(), key=lambda x: x[1], reverse=True)
sec_points = [tess[t[0]].centroid.coords[0] for t in sort_areas[:15]]

清单 7-8: 基于质心的人员分布算法

我们首先通过triangulate函数从清单 7-6 中三角化event_shape对象。结果是一个由Polygon对象组成的集合,表示构成该形状的三角形(记作P[Δ])。然后,我们使用字典推导式创建一个可排序的三角形索引和对应面积的列表。我们使用sorted函数按值对area_dict进行排序。使用降序排列(reverse=True)使我们能够使用前 15 个三角形质心的坐标来估算安保人员的合理分布。图 7-5 展示了结果的部署计划。

图 7-5: 安保人员的质心部署

三角剖分与图 7-4 中展示的完全相同。灰色的加号符号是每个选定三角形的质心。正如你所看到的,这个相对基础的算法结果相当不错。无论你站在场地的哪个位置,都不会离一个或多个安保站点太远。使用这种分散方法可以确保安保人员能够快速响应,无论他们被需要在哪里。唯一的问题是它将一名警卫安排到了场地中不可用的区域,正好是舞台上!我们将在“改进警卫部署”部分中看到如何改进这个算法,并修正该警卫的位置,但现在,我们先来看一个有用的分析工具,它可以帮助我们规划和安排步巡。

估算警卫巡逻时间

周边长度是估算巡逻时间的一个好方法。为了简化假设,我们假设巡逻事件周边的守卫不计入我们已经布置的 15 名守卫。在这种情况下,我们可以安排一个未布置的守卫位置进行巡逻,但这会改变场地内安保与观众的比例(尽管只有轻微的变化)。暂时假设我们在周边有另外两名守卫,他们不计入参与人数。一个守卫位于一个固定点(例如,主入口处),另一个则在周边巡逻。为了安全,巡逻守卫应该与固定守卫有安排好的签到时间。问题是,正常的巡逻应该需要多长时间。这时周边长度就派上了用场。对于一个多边形,其周边长度是构成该图形的各条线段长度之和。为了计算绕公园走一圈需要多长时间,我们可以将外周边的长度除以守卫的预估步行速度。列表 7-9 展示了如何计算这个时间。

perimeter_len = event_shape.exterior.length*10
walk_time = perimeter_len / (1.1 * 60)
print ("%.2f meter perimeter" % perimeter_len)
print ("%.2f minutes" % walk_time)

列表 7-9:计算守卫巡逻时间

复杂形状如event_shape对象的外部length属性是一个LinearRing对象,代表该图形最外层的各个点。为了得到围栏周围的实际距离,我们将LinearRing的长度属性乘以缩放因子。接下来,我们需要估算守卫的步速。根据维基百科,行人过马路的平均步速大约是每秒 1.4 米(m/s),即每小时约 3.1 英里(mph)。我们可以假设我们的安保人员走得稍慢一点(毕竟他们在巡逻时要观察周围的情况)。我们将步速设为每秒 1.1 米,即每小时约 2.5 英里。为了得到每分钟的步速,我们将每秒的步速乘以 60。然后,我们通过时间 = 距离 / 步速来计算walk_time。从列表 7-9 得到的结果应该是:

362.03 meter perimeter
5.49 minutes

我们可以利用这些信息来支持一种政策,即巡逻守卫应每 5.49 分钟在周边的某个特定地点签到。实际上,如果你没有在大约 6 分钟内收到签到,你应该联系巡逻人员。

改进守卫布置

现在让我们通过添加共址的概念来改进我们在列表 7-8 中的警卫部署,共址指的是两个物体在笛卡尔平面上占据相同的空间。通过共址,我们能够判断警卫是否被部署在场地的不可用区域。Shapely 提供了多个函数——如containsintersectsoverlapstouchesdistance——来检查几何对象之间的关系。每个函数都需要第二个对象作为参数,并回答其名称所暗示的问题。例如,contains用来检查 B 对象是否完全位于 A 对象的内部。如果 B 的任何点位于 A 的外部,而 B 的至少一个点位于 A 的内部,则结果为True。还有一个逆向关系函数within,即A.contains(B) = B.within(A)。Shapely 的文档中对每个函数的解释都非常详细。

列表 7-10 扩展了列表 7-8 中的代码,使其不再将警卫站设置在舞台上。

finalized = []
❶ s2 = Polygon(stage)
i2 = Polygon(info_booth)
for guard_station in sec_points:
    i = 0
    new_station = Point(guard_station)
    while any([
      ❷ (s2.contains(new_station)),
        (i2.contains(new_station))
    ]):
        `--snip--`
      ❸ new_area = sort_areas[15+i]
        i += 1
        poss = tess[new_area[0]].centroid.coords[0]
        if poss not in sec_points:
            new_station = poss
            new_station = Point(poss)
    finalized.append(list(new_station.coords[0]))

列表 7-10:重新分配警卫站到可用区域

由于LinearRing对象是空心的,它们实际上不包含其边界内的点,因此我们需要首先将stageinfo_booth对象更改为Polygon对象❶。多边形被假定为填充的,因此在调用contains函数时,位于其边界内的任何点都将返回True❷。通过遍历sec_list中的每一组坐标,我们可以将其转换为Points并检查舞台或信息亭的多边形是否包含该点。如果是,我们可以将其重新分配到其他未被占用的三角形上,从第 16 个三角形(索引 15)开始,并将警卫站分配到新的位置❸。我们重复这个过程,直到找到一个可用的三角形为止。图 7-6 展示了运行改进代码后的结果。

图 7-6:改进后的安全部署结果

当你将图 7-6 与图 7-5 进行对比时,可以看到警卫站从舞台区域移动到了信息亭下方的空旷区域。这个改变将所有 15 名警卫都安排到了场地的可用区域,确保优先将警卫安排到最大的未保护区域。结合这些信息与出席建议和预定的巡逻路线,我们已经为事件的安全计划打下了良好的基础。我们可以根据对场地、活动及其他相关因素的了解,进一步细化这个基本建议,形成一个完整的安全策略,策略中应包括更多安全人员的应急计划和观众溢出区域。

总结

计算几何学在安全中的力量在于能够将物理特征编码为机器可以理解的形式:如点、线段和多边形。一旦你对特征进行了编码,你就可以计算面积、质心位置、周长和对象关系来分析安全问题。你已经看到如何使用几何学描述像公园这样的物理空间,并有效地安排安全人员。你还看到了如何将形状结合起来,通过孔对象制作更复杂的表示。最后,我们还介绍了一些在实际应用中常见的其他常用函数。Shapely 库中还有许多其他可用函数,但本章中展示的这些代表了在几何问题分析中你需要执行的大部分工作。

正如你将在接下来的三章中看到的,我们可以将这些操作结合起来,创造出其他非常有趣的安全工具。仍然有很多研究等待你深入探索。几何学在安全行业中有着悠久的历史,许多研究问题可以重新表述,使得你能够将几何算法应用于其中。当然,这些内容甚至没有涉及到密码学中的几何学,而那是一个独立的领域,需要一本书来深入探讨。如果你对这个领域感兴趣,可以查阅论文《几何密码学:通过角平分法进行身份识别》。^(1) 如果你对如何将几何算法应用于隐私问题感兴趣,你可以研究隐私保护计算几何学领域。

让我们继续探索计算几何学,这次的主题是地理位置数据。在下一章中,我们将结合 MapBox 和 OpenCellID 数据,三角定位一个手机的位置。

第十章:使用数字信息跟踪人们在物理空间中的位置

让我们继续讨论计算几何学的话题,谈到一个有点争议的话题:使用数字信息跟踪人们在物理空间中的位置。全球的执法机构依赖手机追踪来定位和逮捕嫌疑人,这一点已不再是秘密。你可能认为这需要他们获得搜查令,然后从无线提供商那里传唤 GPS 记录,但事实并非总是如此,也不一定严格需要。利用公开可用的信息和基本的几何学,你可以(在某种程度上)准确地定位一部没有 GPS 的手机所在的区域。只要它连接到蜂窝或无线网络,你就有很好的机会定位它。

三角定位过程依赖于知道几个信息:首先,位置——即物理的纬度和经度——以及大量无线网络枢纽的配置,如手机基站;其次,这些设备的大致广播范围;最后,手机在一组网络中可以通信的信号强度。这看起来可能是一个难题,但前两个问题已经由 Unwired Labs 的团队通过他们的 OpenCellID 服务为我们解决了。第三个问题将在下一节中讨论。

一旦你对数据有了基本的了解,我们将探讨一些围绕这个项目的伦理影响。之后,我们将更详细地介绍 OpenCellID API,并讨论如何找到单个基站的位置,使用 API 的地理位置求解器获取地址和准确度估计。

收集蜂窝网络数据

有几种方法可以从手机获取网络信息,从复杂的硬件攻击到使用内置工具的非常简单的方法。硬件攻击超出了本书的范围,但你可以查看《Android 安全内部》^(1) 和《Android 黑客手册》^(2),了解关于 Android 手机的这方面内容,也可以参考《iOS 黑客手册》^(3),了解 Apple 设备的相关信息。

我们需要的网络接口数据看起来非常简单且无害,以至于几乎每个在手机、平板电脑、笔记本电脑或其他支持无线功能的设备上运行的应用程序都能获取到。如果你像我一样,可能会对哪些应用程序可以直接访问你的位置信息非常挑剔,但几乎每个应用程序都有内置的理由要求网络访问:更新。不幸的是,这意味着这些应用程序可以看到哪些网络是可见的,以及它们的信号强度。多亏了 OpenCellID API,这些信息几乎和拥有 GPS 信号一样有效,因此,如果你能利用这些应用程序的某个漏洞,可能会用它来获取这些信息。

作为一个实验,你可以尝试手动扫描你所在区域的蜂窝网络。大多数手机都有一个手动选择蜂窝网络的选项。要找到它,可以搜索手机的型号和“手动选择蜂窝网络”。市面上还有很多应用可以执行此任务,尽管我更倾向于在下载任何应用之前,先使用手机自带的功能。可以说,从这一点开始,我假设你通过某种合法方式能够访问设备的网络数据。我还提供了在开发示例时使用的样本数据,供你跟随学习,即使你无法访问任何设备也可以进行操作。

在本项目中,我们将使用 OpenCellID API,所以我们先花一点时间来讨论它是什么,以及为什么我们要使用它。OpenCellID API 是一项在线服务,存储有关蜂窝网络基站的信息,如位置和网络类型。基站通常是人们所说的蜂窝网络的塔。技术上来说,我们通常看到的塔部分仅仅承载了物理天线;实际操作的大脑部分位于天线底部附近。这些箱子包含了必要的硬件和应用逻辑,用来将设备的流量引导到蜂窝网络提供商的网络基础设施并最终到达目的地。在本章的其余部分,我将交替使用基站这两个术语。

在创建一个免费账户并获得 API 访问令牌后,你将通过在线 REST API 获取数据。除了跟踪物理位置之外,关于蜂窝网络的公开可访问数据在许多安全应用中也很有用。如果你在任何大城市地区旅行,手机会在塔与塔之间切换。通常这一过程会在后台无缝进行,且发生的原因有几个。蜂窝网络通过让设备连接到其他塔来不断平衡流量。当一个基站检测到其流量过载时,它可能会指示新的设备连接到其他塔来处理流量。你的手机还会被编程为优先连接信号最强的基站,然后根据需要依次连接其他信号较弱的基站。你的设备在这些塔之间的切换可能会为攻击者提供机会,诱使他们通过一个足够强的信号,在附近的设备之间插入一个恶意的基站(他们控制应用逻辑的基站)。当你的手机在不同塔之间切换时,公开的已知基站列表能够帮助确保你的设备只连接到合法的蜂窝网络。OpenCellID 的在线门户(opencellid.org) 也可以用来探索某一地理区域内可用的数据。

图 8-1 显示了来自 OpenCellID 的西雅图派克市场区域的地图,数据来自其在线门户。

图 8-1:派克市场区域的网络数据

这张图片是从 OpenCellID 主网站获取的。灰色圆圈表示 OpenCellID 拥有信息的无线电簇。圆圈中央的数字显示了该簇中有多少个无线电设备。你还可以看到一些带字母标签的图钉。这些是单一的网络实例,字母表示用于通信的无线电类型。地图右下角的关键字展示了四种主要的网络类型:GSM、CDMA、UMTS 和 LTE。与本节内容相关的 Jupyter notebook 里有更多关于每种网络类型的详细信息。

这四种网络类型构成了我们将用来根据固定无线电位置集来定位设备的核心基站。还有一种网络类型在地图上没有显示,那就是 Wi-Fi 路由器,在某些情况下,你也可以使用 Wi-Fi 路由器获得更精确的定位结果。问题是,与蜂窝网络塔相比,Wi-Fi 路由器是短暂的。它们可能会关闭,或者更糟的是(对我们来说),在 OpenCellID 数据库没有更新的情况下被重新定位,这会让我们的分析陷入困境。在示例数据中,我们将坚持使用蜂窝基站,但你一定可以自己探索 Wi-Fi 选项。有些在线服务,如 WiGLE (www.wigle.net),它们类似于 OpenCellID,但专注于 Wi-Fi 网络。

每个基站都有一个唯一的国家代码和网络 ID。数据中包含有关网络类型的有效范围估算值,例如大多数 CDMA 天线的有效范围是 1 公里;还包括天线的大致经纬度、其他验证信息,以及更多内容。通过获取几个可见的网络基站并查看它们的服务覆盖区域,我们应该能够确定设备可能位于的地球小部分区域。为此,我们将根据网络类型创建表示每个基站服务区域的多边形。然后,我们将把这些多边形彼此叠加,并使用 Shapely 库的 intersectiondifference 函数找出所有网络共享的区域。

在深入数据的细节之前,我们应该考虑一些隐私问题。作为安全研究人员,我们做出的决策可能会以意想不到的方式对社会的大部分隐私和安全产生负面影响。在下一节中,我们将讨论在进行任何与设备跟踪相关的项目之前需要考虑的一些事项。

跟踪设备和人员的伦理

在某些方面,罪犯反而轻松。罪犯不必考虑他们行为的伦理或道德影响。他们是否让目标面临更高的风险,通常不会是他们关注的重点。另一方面,白帽子黑客在获取或使用位置信息时,必须考虑伦理以及通常涉及的法律障碍。在过去,我通过远程管理工具从公司拥有的设备上获得了类似于本项目数据的记录,这些设备是在员工的笔记本电脑或手机丢失后才得到的。然而,即使这种看似出于善意的使用,也是一个伦理灰色地带。在美国,关于员工隐私权的界限并不明确。^(4) 很容易说,“如果公司拥有一个系统,他们有权追踪和监控它,”但如果人们在非工作时间携带工作系统(比如我自己经常做的事情)呢?有些公司甚至要求管理人员在空闲时间——甚至是度假时——也要随身携带工作手机。这些公司没有技术手段来阻止追踪员工的私人时间,而且法律模糊不清,因此这纯粹变成了一个伦理问题。当涉及到企业实体时,许多人害怕被迫相信它们的伦理行为!

理解这种技术的工作原理,以及如何将其道德地应用于提高安全性而不损害隐私,是我们作为研究人员和分析师的责任。在阅读完这一章后,花些时间查阅你所在地区的相关法律,也许更重要的是,思考一下你认为这种技术的适当和不适当的使用方式。^(5)

对于我们的项目,我使用一台 Android 平板电脑收集了关于我自己的数据。作为数据主体,我知道数据的收集并且允许自己,作为作者,在写作此材料的有限范围内使用这些数据。这里的关键点是,数据主体(我)已经被告知并且给出了同意。获取知情同意进行分析可以在开始之前避免很多伦理风险。如果你在知情同意的范围之外应用这种追踪技术(例如在执法或军事应用中),你必须自己决定适用哪种伦理原则。

现在我们已经讨论了设备追踪的伦理问题,尤其是与我们项目的各种潜在应用相关的问题,我们可以进入问题的核心部分。在下一部分,我们将深入探讨 OpenCellID API,介绍如何调用 API、返回数据的结构,以及如何将这些信息处理成相关的形状对象。

OpenCellID API 结构

从技术上讲,OpenCellID 是一个 RESTful API,使用客户端密钥来识别用户。要访问 OpenCellID 数据,你需要注册其中一个 API 密钥。它是免费的且易于操作:你只需要提供一个电子邮件和使用案例(如“研究”),然后你将获得一个字母数字密钥。你的账户每天有 5000 次请求的限制,但对于大多数应用来说,这已经足够。如果你聪明地缓存响应,你应该能将这些请求分散得更开。

API 支持两种常见的工作流。地理定位是将纬度和经度信息转化为地球上的位置,例如“123 Main Street, Seattle, WA”。地理定位在安全领域内外都经常出现,因此熟悉这个过程是个好主意。地理编码则是反向操作:你获取一个地址并返回该位置的纬度和经度。我们主要会使用 API 中的地理定位部分,但值得注意的是,OpenCellID 还提供 API 调用,帮助显示地图以及监控你的使用情况。在你开发自己的应用时,你将希望利用这些额外的功能。

清单 8-1 展示了一个简单的 API 请求结构。

payload = {
  ❶ "token": "alphanumeric_code",
  ❷ "radio": "cdma",
    "mnc": 120,
  ❸ "mcc": 310,
  ❹ "cells": [{
      ❺ "lac": 23319,
        "cid": 192337670
    }],
  ❻ "address": 1
}

清单 8-1:API 有效负载的结构

每个请求都需要包含你在注册时获得的字母数字token ❶。radio字段 ❷标识我们正在查看的设备的主要网络类型。设置此字段并不会限制你在cells字段 ❹中传递的无线电类型,cells字段代表设备可以看到的基站。我们稍后会再次讨论cells字段,但首先,让我们讨论一下如何标识和分组网络。

每个国家都会分配一个三位数的移动国家代码(MCC)。实际上,大多数国家都会分配几个 MCC,以便将地理区域划分为更小的区域,从而更容易管理任何一个地区的流量。为了唯一标识一个移动用户的网络,MCC 与移动网络代码(MNC)组合成家庭网络标识(HNI),将这两部分信息拼接成一个字符串。

在清单 8-1 中,设备的 MCC 为 310 ❸(这是分配给北美的七个 MCC 中的第一个,编号为 310-316),MNC 为 120,因此 HNI 为 310120。通过 HNI,OpenCellID 可以确定设备属于哪个服务提供商和网络段,当它向 API 发送查询时,你可以从你正在测试的设备上提取此信息,或者传递一些默认的网络 MCC 和 MNC,这也是我们在这个项目的代码中所做的。如果你查找清单 8-1 中的 HNI,你会看到它属于北美的 Sprint Spectrum 网络(imsiadmin.com/assignments/hni)。

现在,回到cells字段,我们看到一个嵌套 JSON 对象的列表。我们可以向 OpenCellID 发送 1 到 7 个无线电标识符,以帮助更准确地定位。如果需要,你甚至可以联系 Unwired 的开发团队来增加这个数量(但这不太可能)。

数据的下一个部分包含了我们希望检索信息的无线电。我们发送的无线电对象可以是任何支持的无线电类型的混合。我们通过两个数字来标识每个无线电,这两个数字基于其物理位置。蜂窝网络被划分为多个地理区域,每个区域可以支持 1 到 65,534 个基站。每个地理区域分配一个唯一的位置区域码 (LAC)。类似于电话号码的区号,LAC 大致描述了基站所在的位置。第二个数字是小区 ID (CID),它标识 LAC 内的每个独立基站❺。你可以把 LAC 和 CID 分别想象成邮政编码和街道地址。它们共同作用,为网络中的每个基站创建一个唯一的标识符。最后,address字段❻告诉 API 返回可读的地址以及其他结果字段。如果你不需要地址,可以通过排除该字段来节省一些字节。

列表 8-2 定义了一个名为lookup_tower的 Python 函数,用于将列表 8-1 中的单个无线电负载发送到 API。

def lookup_tower(payload):
    url = "https://us1.unwiredlabs.com/v2/process.php"
    response = requests.request("POST", url, data=json.dumps(payload))
        return json.loads(response.text)

列表 8-2:调用 API 并解码响应的函数

在运行任何内容之前,让我们首先验证此处提供的 URL(https://us1.unwiredlabs.com/v2/process.php)是否仍然是最新的,并且适合我们的使用场景。Unwired 在全球范围内托管多个 API 端点,因此可能有一个更靠近你或者流量更少(有助于减少延迟)的端点。你可以通过访问 Unwired Labs API 列表unwiredlabs.com/api,然后从左侧选项中选择“Endpoints”来执行此操作。然后,你可以复制地理上最靠近你的 API 端点的 URL。

我们必须在将 JSON 负载传递给请求库之前,将其转换为字符串对象。为此,我们在 JSON 负载上调用json.dumps(简写为dump string)函数。我们通过data参数将该函数传递给请求库。返回的response对象将是一个 JSON 对象,以文本形式传输,除非发生错误,在这种情况下,我们将得到一段糟糕的 HTML 块,且在尝试解码response.text属性时,函数会失败。为了避免这种情况,我们应该扩展生产代码,在调用requests.request时使用try...catch块或其他安全网。

列表 8-3 展示了响应的格式。

{
    "status":string,
    "balance":int,
    "lat":float,
    "lon":float,
    "accuracy":int,
    "address":string
}

列表 8-3:单基站 JSON 查询响应

状态字符串将在请求成功时为"ok",如果 API 检测到问题,则为"error"balance字段保存当天剩余的请求次数。我们可以使用lat(纬度)、lon(经度)和accuracy字段将塔定位到地图上。最后,如果传递address=1参数,address字段将作为字符串保存该信息。图 8-2 展示了清单 8-3 中绘制在地图上的结果。

图 8-2:显示塔范围

地图区域显示的是塔所在的城市——西雅图。图像中央的深灰色圆圈显示了塔的覆盖区域,灰色圆圈中央的图钉代表塔的精确经纬度。每一条白色的交叉线代表城市街道。你可以看到,一个塔的最大覆盖区域相当大——至少有几个街区。如果我们只有这一座塔来定位设备,那我们就得覆盖大量的区域!在下一节中,我们将看到如何通过地理定位多个塔并使用 GeoPandas 和 Shapely 库找到它们重叠的服务区域来进一步缩小区域。

概念验证:从附近的基站定位设备

我们已经了解了如何与 OpenCellID API 交互,以获取我们数据中塔的信息。现在是时候将这些知识结合起来,制作一个应用程序,通过塔的位置来定位设备。我们将扩展清单 8-2 中的lookup_tower函数,以定位从测试设备中恢复的塔列表。我们用来测试应用程序的数据可以在本章的补充材料中的cellular_networks.json文件中找到。

图 8-3 展示了从示例数据中获取的四个塔在地图上的布局。

图 8-3:塔信号重叠

你的眼睛可以轻松地找到所有信号重叠的区域,但对于计算机来说,确定这一点并不那么容易。因此,我们的目标是通过编程识别地图上所有四个塔重叠的区域,并使用 Shapely 生成一个有限的搜索区域。然后,我们将把我们的结果与 OpenCellID API 返回的定位猜测进行比较,看看哪个更准确。

收集塔位置

我们的第一步,如清单 8-4 所示,是使用清单 8-2 中的lookup_tower函数收集每个塔的位置数据。

with open("cellular_networks.json") as f:
    cells = json.load(f)["cells"]
tower_locs = []
❶ for c in cells:
  ❷ payload["cells"] = c
  ❸ tower_loc = lookup_tower(payload)
  ❹ tower_locs.append(tower_loc)

清单 8-4:收集塔的地理位置信息

在加载数据后,我们创建一个for循环来遍历每个单元格❶。我们需要单独传递每个基站,以便获取其地理位置信息;否则,我们将根据你传递给 API 的基站作为参考信息来返回一个位置猜测。我们将从清单 8-1 中加载的单元格内容替换为从networks.json文件中加载的基站信息❷。在生产应用中,这将是你从设备中恢复的、你想要追踪的信息。接下来,我们调用lookup_tower函数❸并将结果存储到一个名为tower_locs的列表中❹。

现在我们有一个包含 JSON 对象的列表,每个对象包含每个基站的地理位置信息,形式为经纬度坐标。这与一种标准对象格式 GeoJSON 非常相似,许多不同语言的程序都能理解这种格式。为了使我们的数据更加灵活和标准化,我们完成将数据转换为 GeoJSON 格式。我们可以通过使用 pandas 数据科学库和它的姐妹库 GeoPandas 来简单地完成这一点,GeoPandas 增加了对几何坐标和操作的支持。清单 8-5 展示了如何将 JSON 数据转换为 GeoJSON 格式。

import pandas as pd
import geopandas as gpd

tower_df = pd.DataFrame(tower_locs)
tower_df.drop(["status", "balance"], axis=1, inplace=True)
geo_df = gpd.GeoDataFrame(
    tower_df,
    geometry=gpd.points_from_xy(tower_df.lat, tower_df.lon)
)

清单 8-5:从基站位置创建GeoDataFrame

首先,我们将 JSON 对象列表转换为传统的 pandasDataFrame,这样我们可以在下一行清理掉任何不必要的字段。我们将删除statusbalance字段,因为它们对分析没有任何帮助。接下来,我们使用 GeoPandas 的GeoDataFrame类将tower_df转换为更适合的地理定位数据,其中包含一个特别的字段,恰如其名,叫做geometry,用于保存DataFrame中每一行的几何表示。在这种情况下,我们使用tower_df中的latlon列来定义Point对象,这些对象将存储在geometry列中。GeoPandas 的points_from_xy函数接受 x 和 y 坐标并返回一个Point对象,GeoPandas 可以用它将形状对象与数据关联起来。你可以在OpenCell_API_Examples.ipynb笔记本的第二个单元格输出中查看geo_df数据的结构。

将地理坐标点转换为多边形

我们将把这些Point对象转换为表示圆形的多边形,但 Shapely 并不关心特定的坐标系统或单位,因此我们首先需要将纬度和经度转换为原生的(x, y)坐标,然后再转换回来。这需要一些复杂的代码来存储中间结果并跟踪单位。它依赖于 pyproj(Python 投影的缩写)和 functools 库来实现。functools 库用于高阶函数,即作用于或返回其他函数的函数。例如,它包括修改调用结构或将低效的调用流程转化为更现代和高效的流程的函数。考虑以下示例:

# A normal function
def complex(a, b, c, d, x):
    print(f"you sent in {a},{b},{c},{d},{x}")

complex函数接收五个必需的参数,并使用格式化字符串打印所有参数。但如果我们知道,在我们的用例中,我们总是会使用相同的前四个参数调用complex,而x是唯一需要更改的参数呢?在这种情况下,我们可以使用部分函数副本来简化调用方式。部分函数允许我们为函数固定某些参数,并生成一个新的函数,我们可以在不包含这些固定参数的情况下调用它。在这里,functools.partial允许我们创建complex函数的一个简化版本,如下所示:

import functools
# A partial function that simplifies the previous one
simple = functools.partial(complex, 1, 1, 2, 3)
simple(5)

simple函数现在包含了complex函数的副本,前四个参数已静态定义为1123。现在,调用simple函数时传入任何值,都会将该值作为x参数传递给complex函数。调用simple函数并传入值5现在等同于调用complex函数,并传入11235,正如我们从代码的输出中看到的那样:

you sent in 1,1,2,3,5

我们将使用functools.partial函数来程序化地创建两个新的函数,处理坐标转换。为此,我们将使用 pyproj 库,它旨在内部转换不同的坐标系统。Proj类可以将地理坐标(纬度,经度)转换为本地地图投影(xy)坐标,反之亦然,这正好符合我们的需求。列表 8-6 展示了我从相关的 GIS Stack Exchange 帖子中找到并修改的函数。^(6)

import pyproj
from functools import partial
from shapely.geometry import Point 
from shapely.ops import transform

def get_shapely_circle(x):
    lat = x["lat"]
    lon = x["lon"]
    radius = x["accuracy"]
  ❶ az_proj = "+proj=aeqd +R=6371000 +units=m +lat_0={} +lon_0={}"
  ❷ wgs84_to_aeqd = partial(
        pyproj.transform,
        pyproj.Proj("+proj=longlat +datum=WGS84 +no_defs"),
        pyproj.Proj(az_proj.format(lat, lon))
    )
  ❸ aeqd_to_wgs84 = partial(
        pyproj.transform,
        pyproj.Proj(az_proj),
        pyproj.Proj("+proj=longlat +datum=WGS84 +no_defs"),
    )
❹center = Point(float(lon), float(lat))
❺point_t = transform(wgs84_to_aeqd, center)
 ❻ buffer = point_t.buffer(radius)
    # Get the polygon with lat lon coordinates.
  ❼ circle_poly = transform(aeqd_to_wgs84, buffer)
    return circle_poly

❽ geo_df["geometry"] = geo_df.apply(get_shapely_circle, axis=1)

列表 8-6:将地理坐标点转换为地理多边形

我们定义了az_proj(即方位投影)字符串,其中包含所有将传递给投影代码的变量 ❶。最重要的变量有+proj,它告诉库使用一种被称为方位等距投影(AEQD)的方法来转换坐标;+R,它保存地球的半径(单位为米);以及+units,它告诉代码该数值单位为米,但更一般地,它告诉库应该转换到哪些单位。我们每次调用时唯一需要更改的两个变量是lat_0lon_0,它们定义了坐标系中的(0, 0)点。当前,我们的数据使用世界大地测量系统(WGS)坐标。WGS 84 是美国国防部定义的全球地理信息参考系统标准,也是全球定位系统(GPS)的参考系统。如果你更习惯使用国际地面参考系统(ITRS),它也是兼容的。^(7)

wgs84_to_aeqdaeqd_to_wgs84函数中,我们创建了pyproj.transform函数的一个部分拷贝❷。functools.partial函数将pyproj.transform函数的前两个参数冻结。在aeqd_to_wgs84函数中注意到,两个对Proj类的调用是反向的。这是因为pyproj.transform函数的前两个参数分别定义了当前和目标的坐标表示方式。反转这两个输入会反转转换方向,在这种情况下是从原生坐标转换回地理坐标❸。由于我们正在创建transform函数的简化版本,我们冻结这两个参数,以适应我们所需的转换方向。pyproj.transform函数的其余参数定义了需要转换的* x y *值。我们将这些参数保持未冻结,并在稍后调用函数时传递。

现在我们已经定义了两个转换函数,我们从经纬度创建一个Point对象❹。Shapely 期望的顺序与你可能预期的相反。如果你尝试通过geometry列直接传递坐标,你将遇到错误"latitude or longitude exceeded limits."shapely.transform函数(与pyproj.transform函数不同)将一个用户定义的函数应用于 Shapely 对象的所有坐标,并从变换后的坐标返回相同类型的新的几何对象。我们将使用shapely.transform函数,通过之前定义的wgs84_to_aeqd函数将点转换为原生坐标❺。Shapely 点有一个buffer函数,用于在点周围添加一定量的空间。本质上,给定一个点和所需的缓冲区空间量,Shapely 生成一组新的点,表示缓冲区边界的位置。我们可以使用它生成一个圆形,表示每个塔的近似覆盖区域。由于我们在 AEQD 转换函数中将单位定义为米,我们也可以传递缓冲区区域的半径(以米为单位)。这很方便,因为 OpenCellID API 返回的accuracy字段的单位也是米。准确度字段中的值描述了纬度和经度的误差。通过将半径设置为无线电的精度来调用buffer,可以创建一个多边形,表示塔的位置的估计。

信号覆盖区域的准确计算有点复杂。如果我们想要非常精确地计算,我们可以计算波传播,但那时我们需要知道塔的类型、功率等级、塔的高度以及任何主要障碍物。事实上,精度字段也为信号强度提供了一个有用的估算。塔的位置精度通常是给定类型塔的最佳信号覆盖范围的 30%到 50%。假设目标位于大都市区域,30%到 50%也是信号衰减的合理猜测,特别是在我们对塔或周围的景观没有更多了解的情况下。因此,我也喜欢将精度字段作为覆盖区域的快速估算❻。通过将精度作为半径传递给buffer函数,我们定义了一个圆,其边界将表示塔的可能覆盖区域。实际上,塔可能并不在圆心位置,但它会位于圆内的某个地方。这意味着实际的覆盖区域可能会根据塔周围区域的地形和建筑结构稍微大一些或小一些,但这将为我们的概念验证提供一个不错的起点。

此时,我们已经为每个塔的xy位置定义了一个圆形多边形,表示其覆盖的服务区域的近似值,但表示多边形外壳的点目前处于原生地图坐标系中。为了将其转换为大地坐标,我们再次调用shapely.transform,这次使用aeqd_to_wgs84函数❼,并返回结果。

最后,我们调用apply函数来获取geo_df中每一行的get_shapely_circle函数结果(设置axis=1表示按行操作而非按列操作),并使用结果覆盖数据中的原始geometry列,生成新的多边形❽。

计算搜索区域

在上一节中,我们解决了项目中的第一个大难题。现在,我们可以将塔的纬度和经度以及精度估算值转换为表示该塔潜在服务区域的几何对象。我们还将几何体外壳周围的点转换回可用于地图的纬度和经度坐标。我们的下一步是找到这些多边形重叠的地理区域,或者更正式地说,A∩(B, C, . . . , N),其中ABC等代表之前步骤中创建的多边形。为此,我们将借用来自 Stack Overflow 的部分代码^(8),该代码执行重复的布尔运算以找到A与其他每个多边形的交集。让我们从定义一个函数开始,处理最简单的情况:返回两个多边形AB的差异和交集。

清单 8-7 展示了划分代码。

EMPTY = GeometryCollection()
def partition(poly_a, poly_b):
    if not poly_a.intersects(poly_b):
        return poly_a, poly_b, EMPTY
    only_a = poly_a.difference(poly_b)
    only_b = poly_b.difference(poly_a)
    inter = poly_a.intersection(poly_b)
    return only_a, only_b, inter

清单 8-7:将多边形划分为差异和交集元素

首先,我们检查最简单的情况:当多边形A与多边形B没有任何交集时。在这种情况下,我们直接返回两个多边形,并附加一个空的GeometryCollection对象。如果两个多边形之间有重叠,我们需要返回三件事。首先,我们返回两个差集,即多边形A不与B重叠的部分,反之亦然。这两个差集分别存储在only_aonly_b中。然后,我们还需要返回这两个多边形的交集,可以使用 Shapely 的intersection函数找到交集。

Listing 8-7 中的代码将用于解决交集问题的主要函数中,该函数使用了一种稍微修改过的扫描线算法,这是一种非常著名的方法,用来高效地处理任意大小的形状集合,并进行一些布尔操作(例如并集和交集)。我们不是在每个点上停下,而是扫过整个多边形,并将其与所有先前已知的多边形进行比较。每对多边形及其子几何体将被反复收集并进行比较,以查看A的哪些部分与其他多边形重叠。这些重叠将被视为几何体的子集,并依次进行检查。

Listing 8-8 显示了主要函数。

def cascaded_intersections(poly1, lst_poly):
  ❶ result = [(lst_poly[0], (0,))]
    for i, poly in enumerate(lst_poly[1:], start=1):
        current = []
        while result:
            r_geo, res_idxs = result.pop(0)
          ❷ only_res, only_poly, inter = partition(r_geo, poly)
          ❸ for geo, idxs in ((only_res, res_idxs), (inter, res_idxs + (i,))):
                if not geo.is_empty:
                    current.append((geo, idxs))
      ❹ curr_union = cascaded_union([elt[0] for elt in current])
        only_poly = poly.difference(curr_union)
      ❺ if not only_poly.is_empty:
            current.append((only_poly, (i,)))
        result = current
      ❻ for r in range(len(result)-1, -1, -1):
            geo, idxs = result[r]
          ❼ if poly1.intersects(geo):
                inter = poly1.intersection(geo)
                result[r] = (inter, idxs)
 else:
                del result[r]
    only_poly1 = poly1.difference(cascaded_union([elt[0] for elt in result]))
  ❽ only_poly1 = eliminate_small_areas(only_poly1, 1e-16*poly1.area)
    if not only_poly1.is_empty:
        result.append((only_poly1, None))
    return [r[0] for r in result]

❾ polys = list(geo_df["geometry"])
❿ results = cascaded_intersections(polys[0], polys[1:])

Listing 8-8: 用于多边形级联交集的扫描线算法^(9)

首先,我们创建result字段,使用列表中的第一个多边形进行交集检查 ❶。然后,我们遍历其余的多边形列表,使用 Listing 8-7 中的partition函数生成所有多边形(B, . . . , N)的交集和差集 ❷。对于这些交集,我们检查几何体,确保没有空的几何体对象被传递 ❸。一旦我们创建了所有这些子几何体,我们可以进行级联并集操作,生成多边形A的剩余部分。这代表了与其他多边形没有交集的A形状 ❹。如果它非空,我们将其添加到当前的结果列表中 ❺。

接下来,我们再次遍历生成的交集 ❻,查看其中哪些与主多边形A也有交集 ❼。我们重复此过程,直到不再有交集需要检查。有时,交集操作会产生一些微小的多边形,这些其实只是一些伪影,我们可以将其丢弃。为此,我们有一个第二个函数,它将每个交集多边形的面积与第一个多边形的面积进行比较;如果小于 1e – 16 × A.area,该多边形将被移除。剩余的多边形将被重新赋值给only_poly1变量 ❽(我们稍后将讨论eliminate_small_areas函数)。最后,我们检查剩余的多边形列表是否为空。如果不为空,我们将其添加到存储在result变量中的结果列表中。

现在我们可以调用 cascaded_intersections 函数,传入由 geo_df 数据中 geometry 列存储的塔服务区形状数据列表。我们创建一个包含在示例 8-6 中生成的塔服务区形状数据的列表,并将其赋值给 polys 变量❾。我们将 polys 列表中的第零个多边形作为第一个参数传递给 cascaded_intersections 函数。这将是算法考虑交集的主要多边形(多边形 A)。我们将列表的其余部分作为第二个参数(多边形 BN)传递给 cascaded_intersections 函数,告诉它这些多边形可能与多边形 A 相交。cascaded_intersections 函数返回一个感兴趣的几何形状列表,我们将其赋值给 results 变量❿。

results 的第零个元素将是多边形 A 的剩余部分,即没有与任何其他多边形相交的部分。第一个元素将是多边形 A 与所有其他多边形的交集。其余元素将取决于多边形的布局,但将遵循模式(ABCD, ABCD...)。我们只需要第一个元素,即所有多边形的交集,但其他结果也可以供你自行探索。我们可能使用这个输出的一种方式是打印出交集结果的纬度和经度的最小值和最大值。这为我们提供了一个完全封闭交集多边形几何形状的边界框。我们可以相当简单地找到这个搜索区域:

x,y = results[1].exterior.xy
print(f"""Search bounded area:
({min(y)}, {min(x)})
to
({max(y)}, {max(x)})""")

首先,我们创建两个变量 xy,用来保存各自的坐标值列表。记住,我们已经将坐标转换为纬度和经度,因此现在需要做的就是打印出每个列表的最小值和最大值,以找到我们搜索区域的纬度和经度范围。测试塔的代码输出如下:

Search bounded area:
(47.61858939197041, -122.35438376445335)
to
(47.6221396080296, -122.34381687278278)

输出中的坐标表示左下角和右上角的坐标,可以用来围绕由级联交集函数产生的多边形形成一个边界框。我们可以将这些信息提供给地面团队,供他们前往该区域进行搜索。

示例 8-8 中的代码依赖于 eliminate_small_areas 函数,与之相比,这个函数非常简单易懂。示例 8-9 展示了去除任何潜在伪影多边形的代码。

def eliminate_small_areas(poly, small_area):
  ❶ if isinstance(poly, Polygon):
        if poly.area < small_area:
            return EMPTY
        else:
            return poly
  ❷ assert isinstance(poly, MultiPolygon)
  ❸ l = [p for p in poly if p.area > small_area]
  ❹ if len(l) == 0:
        return EMPTY
  ❺ if len(l) == 1:
        return l[0]
  ❻ return MultiPolygon(l)

示例 8-9:去除小面积多边形

首先,我们使用isinstance检查传入的多边形是否为单一多边形的实例❶。如果是,我们检查该多边形的面积是否小于small_area参数。如果是,我们返回一个空的GeometryCollection;否则,返回该多边形实例。如果传入的poly参数不是单一多边形的实例,我们断言它必须是MultiPolygon的实例(本质上是一个多边形列表)。如果断言失败(比如你不小心传入了一个字典),代码将引发异常❷。在MultiPolygon的情况下,我们使用列表推导式检查每个单独多边形的面积与small_area参数的关系❸。如果结果列表的长度为 0❹,则在去除伪影后没有多边形,因此我们返回EMPTY。如果列表中仅剩一个多边形对象❺,我们将其作为单个多边形实例返回;不需要再传递一个额外的MultiPolygon对象。如果列表中剩下多个多边形,我们将它们全部返回为一个MultiPolygon对象❻。

在调用cascading_intersections函数后,我们可以绘制结果中的第一个项目,查看标识出的搜索区域。图 8-4 展示了代表所有塔楼交集的多边形。

图 8-4:所有四个塔楼的交集作为一个多边形

如果你将图 8-4 中的多边形形状与图 8-3 中的重叠区域进行比较,你会发现它们非常相似,这意味着我们已经实现了通过程序识别感兴趣区域的目标。我们可以将这些坐标以原始形式传递给任何 GPS 设备,以创建一个更精确的边界搜索区域。

为调查员绘制搜索区域

我们还可以将结果叠加到地图上,以查看我们应该传递给调查员的搜索区域,如图 8-5 所示。

图 8-5:结果搜索区域

该多边形直接覆盖了被称为西雅图中心的区域,这是太空针的所在地。事实上,当我采集样本数据时,我正站在太空针脚下,接近搜索区域的中心。现在,让我们将我们的结果与 OpenCellID API 提供的结果进行比较,如图 8-6 所示。

图 8-6:位置估算比较

浅灰色的外圈显示了由 OpenCellID API 提供的基于四个示例塔楼中三个塔楼的搜索区域(实际上,在移除一个塔楼后,它变得更为准确)。靠近中心的深灰色区域是我们使用基本计算几何方法生成的搜索区域。如你所见,我们已经显著减少了整体搜索区域。此外,值得一提的是,OpenCellID 的结果中心位于我们搜索区域的边缘,这意味着我的实际位置远离搜索区域的中心。

缩小搜索区域

使用基站数据永远不会像 GPS 那样准确或可靠,但我们仍然可以应用一些技术来改善结果。通过改善信号覆盖,你将获得更准确的搜索区域。它们可能实际上更小,但你的信心会更高,从而实现更好的资源利用。

为了进一步缩小搜索区域,你可以利用该区域内的 Wi-Fi 网络(如果有的话)。我不会依赖 Wi-Fi 来找到初始搜索区域,但它们是缩小已定义搜索区域的一个好选择。我是 WiGLE 数据库的粉丝,用于 Wi-Fi 搜索,但正如我之前提到的,OpenCellID API 也支持 Wi-Fi 天线。通过结合这两个 API(以及你可能找到的任何其他 API),你会提高找到有位置信息可用的网络的机会。你可以利用这些网络的有限范围来大幅缩小搜索区域,有时甚至可以缩小到单个建筑物。美国联邦通信委员会(FCC)曾研究过使用这种 Wi-Fi 地理定位作为一种帮助紧急服务调度员找到不知自己位置的来电者的选项。

如果你能够从想要定位的设备捕获到必要的信息,你也可以选择使用信号最弱的基站。理想情况下,你会找到几个信号较弱的基站。通常,这些基站是距离最远的,因此会创建最小的重叠区域。不过,这纯粹是启发式方法,因为弱信号也可能表示较近的基站,且存在更多的障碍物。如果你能捕获多个天线(例如 12 个不同的基站),可以尝试通过每次测试三到四个天线的组合来采取迭代方法。你可以比较结果搜索区域,并确定一种搜索区域热图,其中最可能的地方是那些出现在最多多边形交集中的位置(或者如果你更喜欢的话,就是重叠区域的重叠部分)。

总结

在安全背景下,地理定位的力量是无法被过分强调的。在一个充满手机、不断发展的智慧城市和物联网(IoT)扩展的世界里,人们始终被网络传输所包围。如你所见,无畏的研究人员、企业霸主或积极的黑客可以利用这些信息将其转化为物理位置。结合 Wi-Fi 接入点通常以公司名称命名这一常见商业做法,这就成了一个令人恐惧的精确跟踪工具。在你将这种类型的跟踪系统应用到研究环境以外之前,有很多伦理和法律问题你需要考虑。

有几个数据集鼓励用户通过一种叫做战争驾驶的方式,贡献他们所在地区塔楼的最新信息。尽管这个名字有些反社会(源自黑客历史),但战争驾驶其实只是环绕某个地区,记录可见的网络。一些人甚至将录音设备附加在户外动物身上,这样当动物四处游荡时,它也在为主人贡献网络地图。^(10)

然而,并非一切都充满了阴霾和末日感。在下一个项目中,我们将探讨如何应用相同的原则,将物理位置转化为几何数据,帮助城市规划新的紧急服务。我们将重新审视拼贴话题,并讨论我最喜欢的几何算法之一,Voronoi 图。

第十一章:计算几何在安全资源分配中的应用

在企业安全工作中,你将经常被要求协助进行各种基础设施规划和部署任务,这些任务更多地涉及提供安全性,而非我们通常认为引导信息安全的传统 CIA(三要素:保密性、完整性、可用性)三元组。但不用担心——凭借你正在收集的数学工具,你将能够适应不断变化的挑战,并在其中蓬勃发展。本章我们将重点介绍计算几何中最常用的工具之一,也是我个人最喜欢的算法之一:Voronoi 镶嵌。

我们将帮助俄勒冈州波特兰市规划新消防站的位置,利用现有消防站的位置来进行风险评估。我选择这个项目是因为它展示了如何将应用安全概念扩展到其他领域;同样类型的分析可以应用于警察局、医院、汉堡店或任何其他分布在城市、州、国家或地理区域的公共资源,使其成为你工具箱中最灵活的分析工具之一。

这是高层次的计划:首先,我们将创建一个表示城市的多边形,然后在多边形内放置一些点,表示当前消防站的位置。接着,我们将城市划分成更小的多边形,代表每个消防站所负责的区域。最后,我们将比较这些小多边形的面积,确定哪个消防站负责覆盖的面积最大,并根据需要推荐新消防站的设置,以提高该区域的响应时间。在过程中,我们将研究 Voronoi 镶嵌,并讨论我们实现的一些限制。到本章结束时,你应该能够扎实地理解如何使用计算几何进行资源分配计划。你还应该能够熟练地使用 OpenStreetMaps API 来获取和处理地理空间信息,这将使你能够将资源规划扩展到任何所需的地理范围。

使用 Voronoi 镶嵌进行资源分配

你在第七章中已经看到过镶嵌的应用,当时我们将安全资产部署在公园周围。在那个例子中,我们基于多边形的顶点将平面划分成三角形,然后在这些区域中放置一个点,表示守卫。我们预期守卫会对任何发生在他们区域内的事件做出响应。Voronoi 镶嵌则是反向工作的,我们有一组点(称为种子生成器),这些点分布在平面上,我们希望将区域划分为包含单一点的区域。请看图 9-1,它展示了一个 Voronoi 镶嵌的示例。

图 9-1:随机生成的 Voronoi 镶嵌

灰色圆圈代表了 10 个随机选取的(xy)坐标,这些坐标作为我们 Voronoi 镶嵌的生成器。每条线段代表平面上距离两个或多个生成器等距的位置。线上的点与多个生成器点的距离相同,因此我们将它们标记为生成器之间的边界。标记完所有生成器之间的边界后,我们就得到了一个多边形的马赛克。每个多边形代表平面上的一个子区域。在每个多边形区域中都有一个生成器点,因此我们可以根据哪个生成器点离平面上任意一个不在边界线段上的点最近来对其进行分类。

在第七章中的公园示例中,这就像是将警卫分布到公园中,然后根据他们的位置将公园划分为各自的责任区域。以这种方式处理问题在很多情况下都非常有用,特别是在检查已经到位或无法轻易移动的资源分布时。

严格来说,Voronoi 镶嵌将平面划分为空间区域,其中每个区域的所有点都比任何其他生成器更接近该区域的生成器。与两个或多个生成器等距的点定义了区域之间的边界。为了对消防站项目执行 Voronoi 镶嵌,我们需要定义一个度量空间 X,这只是一个集合(在此情况下,是一个由 2D 多边形定义的平面上的生成器点集合)和一个作用于该集合的度量函数(这里是一个计算点间距离的d函数)。我们的度量空间——也就是我们将进行镶嵌的平面——受到城市边界形状的限制。生成器点将是当前站点的位置,负责划分城市的责任区域。边界数据和生成点都需要处于同一坐标系统中。我们不再像之前的项目那样手动投影坐标,而是利用一个专门构建的库叫做 geovoronoi,它在后台处理坐标的前后投影。

概念验证:分析消防站覆盖范围

这个项目的概念验证相当简单。我们的目标是开发一个应用程序,通过 Voronoi 镶嵌来程序化地定义波特兰市的消防服务区域分布。我们希望程序能找到面积最大的服务区,并将其作为推荐的拆分区域。为此,我们需要定义三项信息。首先,我们需要表示我们计划分析的区域的形状数据,在本例中是波特兰。我已经将这部分数据包含在书籍附录材料中的portland_geodata.json文件里。你也可以通过像 OpenStreetMap API 这样的网络服务获取数据,这也是我最初获取数据的方式。其次,我们需要定义生成点的位置,在这个项目中,生成点是波特兰各消防站的地址。我已经将我在此次分析中使用的 10 个地址包含在书籍附录材料中的station_addresses_portland.csv文件里。第三,我们需要定义在执行 Voronoi 镶嵌时用于测量点之间距离的函数。*

一旦我们定义了这三项信息,就可以进行 Voronoi 分析了。接下来,我们只需找到生成的区域中面积最大的那个;为了实现这一点,我们将再次依赖 Shapely。我们将从讨论距离函数开始。加载区域形状和生成点是有趣的数据检索任务,但距离函数定义了区域之间边界的计算方式,因此这是大部分数学“魔法”发生的地方。

定义距离函数

度量空间包含距离函数 d(p, q),该函数用于确定平面中两点之间的距离。算法通过这个方式来决定哪些点属于哪个区域。距离函数有几种选择:曼哈顿距离、切比雪夫距离、绝对差的和、平方差的和等等。每种都有其优缺点,因此我们将选择最基本、最直观的选项——欧几里得距离,或口语上称为“鸟飞直线”。

两点 pq 之间的欧几里得距离是连接它们的线段(pq)的长度。如果我们将纬度和经度视为笛卡尔坐标系中的坐标,就可以将这个问题视为一个二维欧几里得几何问题,我们可以使用勾股定理来解决:

其中 n 是问题映射到的维度数,或者更一般地说,是定义一个点的向量的长度。所以,如果你在处理 10 维问题,每个点将由一个长度为 10 的向量来定义。在我们的例子中,我们有两个维度,因此 n = 2。我们只需要对终点和起点之间的差值进行平方,将两个维度的平方差求和,然后对结果取平方根。

在这个场景中,欧几里得距离有一个缺点:实际上,你很少能直接穿越一个地理区域而不考虑像树木和建筑物这样的障碍物。此外,像消防车这样的车辆受到街道的限制,并且受交通和其他条件的影响,这些因素决定了它们到达目的地的路径。我鼓励你在我的简化基础上进行扩展,以便在你自己的实现中使结果更加准确和有用。现在,我们已经定义了开始深入探讨问题所需的一切。是时候开始收集定义几何平面所需的数据,从城市边界的形状开始。

确定城市形状

为了获取表示平面边界的多边形,我喜欢使用 OpenStreetMap 团队提供的一个名为 Nominatim 的网页工具(nominatim.org)。其简单且免费的界面允许你获取几项重要信息,如地点 ID、名称的本地化拼写等。你可以直接在网站上查看这些信息,或者请求 JSON 响应以便在你自己的程序中解析,正如我们在这里所做的那样。清单 9-1 展示了如何请求这些信息。

import urllib, requests, json
base = "https://nominatim.openstreetmap.org/search.php"
f = {"q": "Portland OR", "polygon_geojson":1, "format":"json"}
q = urllib.parse.urlencode(f)
resp = requests.get("%s?%s" % (base, q))
resp_data = json.loads(resp.text)[0]

清单 9-1:通过 Nominatim 获取俄勒冈州波特兰市的 JSON 数据

当我们调用 Nominatim API 时,q 参数包含我们要搜索的查询字符串;在这里,我们将其设置为包含城市名称和州缩写的字符串,"Portland OR"polygon_geojson 参数告诉 API 返回表示城市边界的多边形的 geoJSON 形式。这是我们目前最感兴趣的部分,因为它获取了我们定义城市边界所需的形状数据。format 参数告诉 API 我们希望以何种格式返回响应数据。每当 JSON 可选时,它是 Python 的一个不错选择,因为它将解析后的数据处理得像字典一样。我们需要将参数字典编码成可以附加到 URL 的字符串,使用 urllib.parse.urlencode 函数。然后,我们将查询字符串作为 GET 请求的一部分提交,并使用 json.loads 函数解析响应的文本值。返回的值应该是一个列表,包含一个或多个代表与查询匹配的地方的条目。在这种情况下,应该只有一个结果,代表波特兰市。图 9-2 展示了该多边形。

图 9-2:波特兰市的多边形

灰色区域表示该市的市政边界。这是需要由城市消防服务保护的部分。我们需要将坐标转换为形状,但首先让我们将数据格式化为 geoJSON,如清单 9-2 所示。

city_gj = {
  "type": "FeatureCollection",
  "features": [
      {
          "type": "Feature",
          "geometry": resp_data["geojson"],
          "properties": {
            "name": "City Boundary"
          }
      }
  ]
}

清单 9-2:将坐标转换为 geoJSON 特征集合

这里的数据结构是 geoJSON 对象的顶级定义,该对象的 type 属性为FeatureCollection,表示可以在适当命名的features键下嵌套特征列表。每个特征是一个嵌套的 JSON 对象,类型为Feature。每个特征需要一组坐标来定义其几何形状;在这里我们使用resp_data["geojson"],即在清单 9-1 中从 API 返回的 geoJSON。我们还可以添加更多属性来存储自定义信息,这些信息可以用于组织或为我们的分析提供支持。properties键后面跟着一个嵌套的字典,定义了属性名称和值,其中字典的键表示属性名称,值表示属性值。键仅限于字符串字面量,但值可以是任何合法的 JSON 对象,因此你可以在附加到特征的属性信息上进行非常有创意的操作。

下一步,如清单 9-3 所示,是将结果 geoJSON 信息转换为存储在GeometryCollection中的shape对象。

from shapely.geometry import GeometryCollection, shape
city_shape = GeometryCollection(
    [shape(f["geometry"]).buffer(0) for f in city_gj["features"]]
)
city_shape = [geom for geom in city_shape][0]

清单 9-3:将城市几何转换为 Shapely 形状集合

首先,我们通过传入组成集合的几何对象列表来创建一个GeometryCollection。我们使用列表推导遍历在清单 9-2 中创建的city_gj变量中的每个要素。对于每个要素,我们将几何参数传递给shape构造函数。结果是一个shape对象,表示 geoJSON 要素的坐标。有些城市由多个多边形表示,因此此函数将遍历构成城市的所有多边形,并将每个多边形转换为一个shape对象。city_shape现在是一个包含单个MultiPolygon对象的GeometryCollection。我们可以使用另一个列表推导访问MultiPolygon。由于只有一个项目,我们可以通过索引0从列表中提取它。如果你有更多MultiPolygon对象需要处理,应该遍历每个对象。city_shape变量中的MultiPolygon数据现在表示我们将使用我们定义的度量空间进行镶嵌的平面。现在是时候获取车站的位置来创建我们的发电机列表了。

收集现有消防站的位置

如前所述,我们将使用现有消防站的地理位置作为我们分析中的区域发电机。书籍附赠材料中提供的文件station_addresses_portland.csv包含了市区内 10 个消防站的名称和地址。pandas 库提供了一个方便的函数,可以将 CSV 文件中的数据加载到DataFrame中,如清单 9-4 所示。

import pandas as pd
stations_df = pd.read_csv("station_addresses_portland.csv", names=[
    "name", "street", "city", "state", "zip"
])
stations_df["addr"] = stations_df.apply(row_to_str, axis=1)

清单 9-4:将数据快速而简便地加载到DataFrame

虽然有专门用于处理 CSV 数据的库,但我更倾向于利用 pandas 的内置 read_csv 函数,因为它灵活且已经包含在我常用的库中。当我为这个项目准备数据时,我手动使用 Google Maps 汇总了车站地址列表。我没有添加标题行,这就是为什么我们通过 names 参数传入一个列名列表。在实际的咨询工作中,我会期待客户提供地址,因此你可能需要调整文件解析方式,以匹配提供的数据格式。接下来,我们创建一个方便的 addr 列,将完整的街道地址作为字符串存储,通过应用一个名为 row_to_str 的函数,该函数简单地返回 streetcitystatezip 列的值,使用空格分隔。我们传递 axis=1 告诉 pandas 我们想要将函数应用于整行,而不是列中的所有值。我们将使用 addr 列来简化地理位置 API 的搜索。

一旦我们将地址收集到 DataFrame 中,我们可以再次利用 OpenStreetMaps API(通过 geocoder 库)将这些地址转换为地理坐标点。在清单 9-5 中,我们定义了 locate 函数,将单个地址转换为位置数据。

import geocoder
def locate(addr):
  ❶ g = geocoder.osm(addr)
    data = g.json
  ❷ if data is None:
        return None
  ❸ return {
        "address": data["address"],
        "lat": data["lat"],
        "lon": data["lng"],
        "osm_id": data["osm_id"]
    }

清单 9-5:将地址转换为地理坐标点

调用 API 的主要工作被 geocoder.osm 函数 ❶ 抽象化处理。传入查询字符串(在本例中为地址)会返回一个包含 API 响应的对象。库在响应对象上提供了一个方便的 json 参数。如果 JSON 响应为 None ❷,我们返回 None 以表示 API 未能找到任何与输入查询相关的内容。如果返回了 JSON 对象,我们提取包含重要信息的子集,例如纬度和经度,并将其作为字典 ❸ 返回。我们还会记录 osm_id,以便在未来进行快捷查找或在多个结果中选择。

为了收集所有车站的坐标点,我们将在一个循环中调用 locate 函数,对每个地址进行处理,并将结果存储为一个列表。由于我们之前已经从车站数据创建了一个 DataFrame(清单 9-4 中的 stations_df 变量),我们可以利用 pandas 的 apply 函数来处理幕后繁琐的工作。然后,我们将位置数据转换为独立的 GeoDataFrame 对象。清单 9-6 展示了如何进行转换。

import geopandas as gpd
locations = stations_df["addr"].apply(locate)
locations = [a for a in list(locations) if a is not None]
loc_df = pd.DataFrame(locations)
geo_df = gpd.GeoDataFrame(
    loc_df,
    geometry=gpd.points_from_xy(loc_df.lat, loc_df.lon)
)

清单 9-6:从车站位置创建 DataFrame

在我们调用apply之后,locations变量包含了一个字典对象的列表,每个字典代表一个火车站,存储了我们分析所需的地理坐标。我们筛选掉位置为None的实例,然后使用这些数据创建一个名为loc_df的地点DataFrame。最后,我们可以将这个常规的 pandas DataFrame转换成一个更合适的 GeoPandas 库中的GeoDataFrame对象。图 9-3 展示了结果在地图上的显示。

图 9-3:作为点的消防站位置

之前定义的多边形内的黑色圆圈显示了站点位置。如果你数一下地图上的位置,只有 10 个站中的 8 个是显示出来的。locate函数未能找到两个地址的坐标。如果这个项目是用于生产环境,我们可能需要从多个来源获取坐标,以提高成功的概率。你也可以手动查找这些坐标,或者请客户提供缺失的信息。现在,我们将从分析中删除这两个地址,继续进行下去。

现在我们已经收集了所有必要的数据,可以开始执行实际的镶嵌操作了。

执行 Voronoi 分析

一如既往,Python 中有多种执行 Voronoi 镶嵌的选项,但在处理地理信息时,最简单的选择是名为 geovoronoi 的库,它为我们处理坐标投影和边界工作。它接收一组坐标,并使用 SciPy 后台计算 Voronoi 区域。在典型的 Voronoi 图的边缘,区域边界会延伸到无穷大,这通常不是我们想要的行为,因此 geovoronoi 库允许我们使用周围区域的形状(在这个例子中,是城市的多边形形状)来裁剪 Voronoi 区域,使其适应提供的形状,从而使边缘区域变得有限。该库还使用 Shapely 来管理形状操作,使其非常适合这个项目。

清单 9-7 展示了如何使用该库与之前收集的数据一起创建镶嵌。

import numpy as np
from geovoronoi import voronoi_regions_from_coords
points = np.array([[p.y, p.x] for p in list(geo_df["geometry"])])
poly_shapes, pts, poly_to_pt = voronoi_regions_from_coords(
    points, city_shape
)

清单 9-7:将地址转换为地理坐标点

第一步是使用列表推导将表示消防站的点转换为 NumPy 数组,遍历之前创建的geo_df DataFrame对象的geometry列。然后,我们可以调用 geovoronoi 库中的voronoi_regions_from_coords函数,将点数组(points)作为第一个参数,将边界多边形(city_shape)作为第二个参数。

该函数的结果是一个包含三项有用信息的元组。poly_shapes变量包含一组 Shapely Polygon对象,表示在 Voronoi 网格生成过程中创建的多边形区域的形状。pts变量包含一组Point对象,表示生成器的坐标。如果你还没有使用 GeoPandas 或其他方法创建它们,这些信息会很方便。poly_to_pt变量包含一个嵌套列表,其中每个poly_shapes中的区域都有一个列表,表示该区域内生成器的索引。索引指示哪些生成器属于此 Voronoi 区域。通常情况下,这个区域只有一个点,因为多个消防站不应共享相同的位置,但也可能存在多个生成器点具有相同位置的情况。在这种情况下,所有这些点都会在poly_to_pt中被索引。

图 9-4 显示了创建的区域以及每个区域的生成器。

图 9-4:一个 Voronoi 网格,显示了每个站点的责任区域

八个区域由黑色轮廓划分。每个多边形内部的区域可以视为生成该区域的消防站的简单责任区域(AOR)。之所以称之为简单,是因为它没有考虑到可能影响响应站点选择的障碍物(例如水体)。尽管如此,这些信息仍然是构建模型的一个良好起点。我们可以看到,位于城市中心附近的消防站分布,使得边缘站点拥有最大的 AOR。从视觉上看,左上角的 AOR 似乎最大,但我们可以使用area参数轻松验证这一点,如 Listing 9-8 所示。

winning = 0
winner = -1
❶ for i in range(len(poly_shapes)):
    ps = poly_shapes[i]
    if ps.area == winning:
      ❷ if isinstance(winning, int):
            winner = [winner, i]
        elif isinstance(winning, list):
            winner.append(i)
  ❸ elif ps.area > winning:
        winner = i
        winning = ps.area

Listing 9-8:找到最大的 AOR

我们首先遍历poly_shapes变量中的每个形状,按照列表长度进行迭代 ❶。这样迭代可以让我们在函数执行过程中跟踪索引。在一些罕见的情况下,可能会有多个区域具有相同的面积。遇到这种情况时,我们将winner变量转换为列表,并跟踪所有平局情况 ❷。在更常见的情况下,两个区域的面积不相等,因此我们检查当前区域的面积是否大于当前获胜区域的面积 ❸。如果是这样,我们更新winning值和winner索引。一旦所有区域都被检查过,winner变量将包含一个或多个区域索引,我们可以利用这些索引在poly_shapes中查找形状(或多个形状)。我们也可以用它来查找poly_to_pt中的站点(或多个站点)。

图 9-5 显示了将区域绘制到地图上的结果,以及负责该区域的站点位置。

图 9-5:最大区域及其生成器位置

再次强调,深灰色区域代表了关注区域,而深灰色圆圈显示了响应的消防站位置。因此,你可以合理地认为,在该区域内的某个位置增设一个消防站,将改善该地区的响应时间和资源可用性。

你可以通过导航到本章的附加材料目录并运行Emergency_service_poc.py脚本来运行概念验证代码,方式如下:

python Emergency_service_poc.py

一旦代码完成加载数据并进行 Voronoi 分析,它将打开一个浏览器标签页,访问http://127.0.0.1,该页面通过 Plotly 和 MapBox API 展示解决方案。

算法的局限性

在本章中,我们仅专注于问题的地理部分,这是一个很好的起点。然而,这种狭窄的范围确实存在一些局限性。我之前已经提到过距离函数的局限性,但如果你在现实中被要求做出类似的建议,还需要考虑其他因素。

其中一个问题是,并非所有消防站的装备都相同。有些消防站配备更多的消防车,有些配备不同类型的专业设备(比如用于勘察大面积森林的飞机,或者用于进行港口巡逻的船只)等等。设备的多样性意味着不同的消防站可能更适合处理不同的问题。如果将一座专门装备的消防站设置在不需要这些设备的地区,那将是资源的极大浪费。资源分配的另一个问题是,有些消防站更适合应对较大的区域。例如,一座有着广阔区域且拥有 10 辆消防车的消防站,可能比一座区域较小但仅有两辆车的消防站更有效地进行巡逻。你可以通过添加更多关于消防站资源和专业化的信息来改进这一分析。在创建 Voronoi 区域时,你需要向市内相关人员咨询新消防站的装备计划,并将其考虑进分析中。

最后的考虑因素是,消防站并不会看见这些边界。一个地区的火灾可能也会带来来自附近地区的响应人员。消防部门尽力将任何给定区域的负载分配到两个或更多的消防站,以便更快速地控制大型火灾。火灾越大,所需的资源就越多,因此可能需要将一个地区的资源调配到另一个地区来提供帮助。严格划分区域为 AORs(责任区域)可能会导致你建议一个完美分配 AOR 的消防站位置,但仍然无法让新站点在有意义的方式上帮助现有站点。

为了避免这个局限性,我建议进行多次分析。对于数据中的每个站点,你可以通过一次移除一个站点并重新计算区域,检查其他站点如何影响其所在区域。这类似于在问,“如果站点 B 无法响应,站点 A 的职责将如何变化?”通过叠加结果区域,你将看到哪些站点承担了更多的共享责任,因为该区域在移除所有其他站点后,其面积变化最大。一个承担了大量共享责任的站点可能会遭受设备疲劳或身体疲惫,因此你可以建议将位于过度负担站点附近的其他站点调动,以更好地分配负载。同样,承担最多共享责任的站点可能拥有合适的设备和人员来处理额外的工作。

所有这些内容旨在向你展示,尽管 Voronoi 图非常有用,但它们并不总是最终的答案。与所有分析一样,你对模型添加的准确性和细节越多,结果在现实世界中的适用性就越强。在进行任何类型的资源分析时,了解这些资源如何与其所在区域以及彼此之间相互作用同样重要。这样做将使你能够为模型做出明智的选择,并克服这一基本框架的一些局限性。

总结

在本章中,我们介绍了最著名的计算几何算法之一——Voronoi 镶嵌。我们看到它如何应用于与资源分布相关的现实问题,并且如何根据问题的需求进行扩展。我们还讨论了该实现的一些局限性,以及你可以自行改进的方式。我希望你能将这个框架加以拓展,以适应你自己的项目。

有大量优秀的研究材料,涉及这些镶嵌的不同应用,从安全到神经学,涵盖了所有领域。我建议你阅读研究论文《使用 Voronoi 镶嵌合理化警察巡逻区域》^(2),这是另一个将此分析应用于改善紧急服务的例子。作为安全分析员,你会发现许多机会来展示你的资源分布知识。在本书的最后部分,我们将重新审视镶嵌,以规划艺术画廊问题的安全资源分布。

在下一章,我们将通过我最喜欢的项目之一——人脸识别系统,来总结我们对计算几何在安全领域应用的探索。尽管其规模和几何结构与我们迄今为止处理的任何内容截然不同,但基本思想是相同的。我们将继续使用 Shapely 来处理几何部分,但现在我们将加入一些机器学习的元素,这将为我们提供所需的工具,以便构建实现面部特征程序化识别所需的高度复杂分析。*

第十二章:面部识别的计算几何学

让我们抛开资源分配的世界,看看计算几何学如何帮助你进入另一个领域:面部识别。面部识别是检查面部特征并确定其是否与先前见过的面部匹配,并且如果匹配的话,匹配的程度。大多数婴儿在三到四个月大时就能识别熟悉的面孔,但不幸的是,仅仅因为婴儿能做到这一点并不意味着这很容易,至少对计算机来说并非如此。我们需要结合计算几何学和机器学习算法,以便我们的程序能够达到类似的水平。

我们将从简要介绍面部识别开始本章。然后,我们将介绍主要的算法,并像往常一样,将其应用于概念验证中,涵盖加载、清理和建模存储在面部图像数据库中的数据。我们将处理数据库中的每一张图像,提取最重要的面部特征,以确定图像中的人是谁,然后我们将利用这些信息建立一个模型,将一张前所未见的面部图像与相应的人匹配。

为了实现这一点,我们将依赖各种机器学习(ML)工具。ML 算法旨在通过两大类解决方案来识别或“学习”输入数据与输出值之间的关系:无监督学习和监督学习。

监督学习中,我们向算法提供一组输入数据,并提供我们希望程序为这些输入学习的正确输出(称为其类别)。在我们的案例中,输入数据将是一个面部的几何特征,而我们想要预测的类别是与之相关的人的姓名,这使得这是一个离散分类问题。如果我们想要预测的类别值是一个连续的数字(例如房子的价格),这将被称为回归问题(你可能已经熟悉线性回归,它是统计学中的一个概念)。

无监督学习旨在发现先前未知的关系,主要通过根据不同的输入将数据进行聚类,以发现有趣的分组。因为我们在开始时不知道目标是什么,所以下无监督学习有时也被称为探索性分析。在这个项目中我们不会做太多的无监督学习,所以我会把这个话题留给你自己深入研究。当你完成本章后,你应该能熟练处理图像数据,提取几何面部特征,并训练监督学习分类器,用于你自己的面部识别项目。

面部识别在安全中的应用

今天,面部识别在安全领域的应用并不难找到。面部识别系统已经成为生活中广泛应用且被普遍接受的一部分。已经从这项技术中受益的行业包括零售商店、赌场、手机制造商和执法机构。然而,这并非一直如此。这项技术的核心自 1960 年代以来一直在缓慢进展,当时伍德罗·威尔逊·布莱德索(Woodrow Wilson Bledsoe)发明了一种方法,允许人们通过电子表面和导电笔手动编码面部几何形状。使用导电笔,用户可以标记出一组标准化的面部特征,如鼻梁、眉毛和下巴。然后,程序会测量这些点和形状之间的几何关系,并创建输入面部的几何图。数据随后会与先前记录的面部痕迹数据库进行比较,以确定与该面部最匹配的人的姓名。当然,这种方法既耗时又容易出错。在接下来的四十年里,研究人员通过定义更多的标准化测量点和使用图像分析技术自动识别面部照片中的这些关键点,持续改进算法。

直到本世纪初,面部识别才开始从科幻小说和研究实验室走向现实。2001 年 1 月发生了一个备受关注的案例,当时佛罗里达州坦帕市使用面部识别系统记录并分析了超级碗 XXXV 上每位观众的面部,希望能够发现那些有逮捕令的罪犯。该程序被认为成功地识别出了几名出席的轻微犯罪分子,但由于高昂的成本和大量的误报,整体上被视为失败。更糟的是,它引发了包括电子前沿基金会(Electronic Frontier Foundation)和美国公民自由联盟(American Civil Liberties Union)在内的隐私倡导团体的强烈反弹。^(1)

尽管负面宣传没有显著减缓这项技术的发展,但它却未能阻止技术的快速增长。佛罗里达州再次因成为首批将面部识别技术作为警察接受工具的州之一而登上新闻。2009 年,皮内拉斯县警长办公室宣布了一项程序,允许警察访问佛罗里达州公路安全和机动车辆管理局的照片档案。两年内,估计有 170 名副警长配备了可以立即与数据库中的面部进行交叉验证的摄像头。此后,廉价的处理能力、不断降低的数据存储成本以及大量的面部数据集,使得这项技术从政府项目进入了安全程序员的工具箱。我们将在下一节 “面部识别研究的伦理问题” 中讨论更多隐私和伦理方面的问题。

现在,你可以以一台普通相机的成本建立一个有效的面部识别系统($20 至 $40 的树莓派摄像头模块非常适合这个用途),再加上一些云处理费用。我将在概念层面保持平台无关,但每个主要的云服务提供商都有一些产品,可以让你将我们所编写的代码转换为一个分布式可扩展的版本(我们将在第十三章中进一步讨论云部署)。

最难的部分是收集图像数据库。为了获得一个良好的面部识别数据集,你需要在不同的光照条件和不同的面部姿势下拍摄同一个人的多张照片。面部特征需要具有可区分性,因此对比度也很重要。我们将在本章稍后的“处理图像数据”部分进一步讨论图像质量。目前需要记住的是,数据集中图像的质量将对机器学习算法区分面部的能力产生重大影响。旧的、模糊的、颗粒感强、对比度低的照片会让这一过程变得更加困难,甚至可能变得不可能。

我们将使用由埃塞克斯大学计算机科学与工程系发布的面部识别数据集。^(2) 数据集中的一部分(标注为faces94)相对静态。研究人员让受试者坐在距离摄像机固定的位置,并要求他们在拍摄一系列图像的同时讲话。讲话引入了面部表情的变化,这使得底层的分类算法能够理解个体面部形状的变化,并有更大的机会正确分类输入图像,即使面部姿势是算法之前未见过的。数据集的第二部分(标注为faces95)则更加动态,通过要求受试者向摄像机走近,从而在拍摄一组 20 张图像时引入了尺度、视角和光照的变化。向前移动使得后续照片中的头部看起来更大,也改变了面部的投影阴影和高光。最后,这些图像的背景是一块红色的窗帘,这也增加了难度,因为不完美的表面可能使算法在检测面部特征的边缘时面临挑战。能够在这些变化的情况下正确分类面部,将使你的程序在实际环境中更可靠地运行,因为你可能无法每次都获得一个清晰、稳定的图像,背景也可能不总是坚固或静止的。

面部识别研究的伦理

在继续之前,我们来谈谈人脸识别研究的伦理问题。能够根据人脸图像自动识别一个人有许多潜在的用途和滥用方式。人脸识别软件可以被急救人员用来在灾难发生后识别受害者,或者被独裁者用来识别政治活动家。作为分析师和开发人员,在处理人脸识别项目时,你必须非常小心。你永远不知道你的软件最终会落入谁的手中。

首先,你应该熟悉所有可能适用于你情况的隐私法律。像欧盟的《通用数据保护条例》这样的国际法规认为生物识别数据,如人脸分析模型,是个人可识别信息,因此要求在其收集、处理和存储过程中实施更严格的安全控制。由于错误率高且在身份错误识别时可能造成严重后果,一些美国城市(如马萨诸塞州的波士顿)甚至禁止警方使用人脸识别技术。了解适用于你项目的法律和法规,将帮助你更轻松地应对其他伦理问题。避免隐私方面伦理和法律问题的最佳方式是获得被纳入数据中的人的知情同意。我强烈建议你拒绝任何无法或不允许收集知情同意的项目。

除了明显的滥用行为外,还有一些更难以察觉的伦理问题。种族和族裔偏见仍然是开发人脸识别模型时的关键关注点。人脸识别算法通常能达到 90%以上的平均分类准确率,但研究人员已表明,这一错误率在不同人口群体中并不均等。几项独立测试发现,面部识别的最低准确率通常出现在 18 至 30 岁之间、肤色较深的黑色女性身上。这些无意的偏见是数据收集者在技术和社会选择中所做决策的结果。比如相机镜头类型、数据收集地点等决策,都会对数据中人口的整体代表性产生微妙但明确的影响。

尽管有这些伦理问题,我仍然将这个面部识别项目放在这里,因为我认为它提供了一个极好的学习机会。我们将使用公开可用的数据,这些数据是在个人同意并知晓图像将用于像我们这样的研究的情况下收集的,这是完全符合伦理的。我选择的数据集相对较小,代表了有限数量的人群。这可能导致我们做出过于乐观的性能预测,因此我们不希望将其用于开发任何类型的生产系统。不过,它会作为一个很好的起点。我们可以用它来说明工作流程,甚至测试算法的某些部分。当你准备为一个真实项目开发面部识别系统时,你将拥有知识和工具来收集一个真正能准确反映人类多样性的数据集。

面部识别算法

尽管我们使用的是机器学习算法,但面部识别过程的核心与 Bledsoe 在 50 多年前创建的过程仍然非常相似。我们将使用 68 个面部特征点来为面部数据库创建几何图,随后利用这些图来训练机器学习算法,将输入的面部图像与之前见过的面部进行比较,并预测最接近的匹配。一般而言,面部识别是一个计算机视觉问题:它涉及教计算机识别编码在视觉数据中的信息,如图片和视频。这也属于多类分类问题的范畴,其中需要预测的类别来自三种或更多的潜在分类。当多类算法运行时,它会将输入与为每个类别记录的数据进行比较,并确定输入最可能属于哪个类别。我们将把数据集中的每个人当作我们感兴趣的类别,输入则是包含已分析过的个体面部的图像。我们将使用的算法是监督学习算法;再次强调,这意味着在训练我们的算法或模型时,它将访问正确分类的列表,并利用该列表纠正先前的错误并改进未来的预测。

存在大量潜在类别(最终分析中有 222 个独特个体),而每个类别的样本量相对较小(每个个体约有 20 张图片),这使得我们的目标更加困难。为了解决这个问题,我们将为每张图片收集大量统计数据,并让算法决定哪些测量子集能够提供最佳的决策能力。

使用决策树分类器

分类是通过一个叫做随机森林分类器的算法处理的,它是决策树分类器的扩展版本。决策树算法有许多优点:它们训练速度快,能生成易于理解的模型,并且在面部识别等多分类问题中表现良好。我们通过一个经典示例来说明它们是如何工作的。假设我们想编写一个程序,根据天气预测某人在某一天是否可能出去打高尔夫球。决策树是这类问题的理想选择,因为它将生成一系列规则,我们可以用这些规则来检查某一天的天气情况。请参考图 10-1 中的决策树。

图 10-1:高尔夫决策树示例

在图 10-1 中,你可以看到每个分支表示数据中的一个布尔决策(例如Outlook_overcast <= 0.5)。阅读决策树时,从最顶部的框(称为根节点)开始,沿着正确的逻辑分支向下,直到到达最底部的框(称为叶节点叶子)。一些关于生成决策的底层数据统计信息会列在布尔决策下方。每一行(在数据科学术语中称为实例)都会一次性通过决策树,直到它到达某个叶节点;在这个过程中,算法会记录数据如何影响树的生长。最简单的统计信息是samples。这是在创建过程中,经过此决策点的总行数。

在图 10-1 中,你可以看到根节点收到了 14 个数据样本。由于这是根节点,它处理了数据集中的每一行,因此这也可以被解释为算法开始时的数据摘要。你可以看到values统计中的每个类别的计数。对于数据中每个可能的类别,有一个整数表示该类别中的行数。在我们的示例中,有两个潜在的类别,Not_PlayPlay。再次查看根节点,你可以看到值[5,9],这意味着这 14 个样本中,有 5 个属于Not_Play类别,9 个属于Play类别。

gini统计信息包含基尼不纯度系数,你可以将其看作是到达该节点的类别分布的度量,称为该节点的纯度。正式地,基尼不纯度系数可以写作

其中 n 是数据中类别的数量,p[i] 是实例被分类为第 i 类的概率。节点的结果得分范围在 0 到 1 之间。如果节点中的所有实例都属于单一类别,则该节点是完全纯净的,Gini 得分为 0。得分为 1 表示实例类别是随机分布的,无法预测。介于这两个极端之间的得分表示某种程度的类别纯度,得分越低,纯度越高(因此对于决策目的越好)。目标是找到只包含单一类别数据的纯叶节点(Gini 得分为 0)。这意味着导致该叶节点的逻辑能够在类别之间做出完美的决策。

要查看这个例子,请沿着根节点的False分支(在图 10-1 中向下和向右)进行操作。你可以通过观察没有布尔表达式的框顶部分,并且没有从中延伸出来的分支来判断这是一个叶节点。class统计量显示了每个节点的多数类;在处理这样的叶节点时,我们可以将其视为到达该点的数据的可能类别。考虑到这一点,我们可以逻辑地解读这个分支为“如果天气是阴天,则预测Play类”,因为我们处理的是布尔值(0 或 1),而决策标准是Outlook_overcast > 0.5samples和计数以及values统计量显示有四个样本到达了这个叶节点,且所有样本都是Play类。

在图 10-1 中,所有叶节点都只有单一类别,因此这是一个完全纯净的树。当然,通常情况下并非如此,可能会有一两个来自其他类别的异常值(称为离群点)出现在一个叶节点中,而该叶节点的主要类别却是另一类。在这种情况下,你可以尝试找到更多的数据划分,以提高每个叶节点的纯度,但最终你必须接受性能为“足够好”,因为很难找到一个完美的划分。

在我们的面部识别问题中,我们将通过将输入的面部信息转换为几何信息,然后将这些信息传递到决策树中,直到到达叶节点,在该叶节点使用多数类来预测最可能匹配输入面部的主体。虽然算法所做的布尔决策比Outlook_overcast > 0.5更复杂,但原则是相同的。

传统决策树的问题在于,它容易受到数据初始条件和配置的影响,因为它按顺序处理样本。对同一数据的洗牌版本重新运行相同的决策树算法,很可能每次都会生成显著不同的树。这意味着,如果你计划使用决策树,你需要用不同的数据组合训练多个版本,以确保性能是可重复的(在此上下文中,性能指的是预测每个类别的准确度)。这促使研究人员设计了随机森林。随机森林算法通过半随机化的起始数据(称为自助法)反复创建单独的决策树。为了分类一个新的数据样本,该实例会沿着每棵生成的树传递,结果的类别预测会被统计。最后,所有树的猜测中多数的类别被预测为最可能的分类。

通过不同数据组合生成大量决策树将有助于确保整体预测不太容易受到任何给定树的起始条件的影响。我们将在开始构建概念验证模型时更详细地讨论随机森林,但在此之前,我们需要先确定如何收集所需的数据。让我们先来看看如何将一张面部图片转化为一组几何数据。在接下来的部分,我们将讨论如何找到重要的面部特征并将它们转换为数值表示。

表示面部几何形状

定义我们的面部识别应用程序的第一步是弄清楚如何将一张面部图像划分为可测量的形状。我之前提到过,我们将使用图像中的 68 个点来标记面部特征。为了节省开发时间并通过更少的前期编码实现我们的目标,我们将利用一个先前训练好的机器学习模型,shape_predictor_68_face_landmarksdlib.net)^(3),它可以识别正面人脸上的兴趣点。图 10-2 显示了大致标出在面部上应落的位置的 68 个点。

图 10-2:由算法生成的面部兴趣点(图片来源:i.stack.imgur.com/OBgDf.png

几何特征图包括下巴轮廓(点 0–16)、左右眉毛(点 17–21 和 22–26,分别)、鼻子(桥部为点 27–30,底部为点 31–35)、左右眼睛(点 36–41 和 42–47,分别)以及嘴巴(嘴唇外部为点 48–59,内部为点 60–67)。当算法接收到一张面部图像时,它会调整每个点的位置,尝试与输入面部的定位相匹配。我们将使用这些点的调整位置来创建 Shapely 形状,代表不同的面部特征。然后,我们将计算一些关于面部的几何统计信息,例如眼睛之间的距离、鼻子的长度等,以创建面部的统计表示。 清单 10-1 显示了如何加载模型。

import dlib
detector = dlib.get_frontal_face_detector()
predictor = dlib.shape_predictor(
    "facial_model/shape_predictor_68_face_landmarks.dat"
)

清单 10-1:加载面部关键点检测器

该模型是 dlib 库的一部分,dlib 将多个 C++ 函数封装为 Python 代码,允许你利用 C++ 的计算速度进行科学计算,同时使用 Python 友好的语法来处理其他部分。get_front_face_detector 函数返回一个先前训练的模型,用于基于称为 方向梯度直方图 (HOG) 的方法来检测图像中的人脸。该检测器计算图像局部区域中梯度方向的出现次数,这意味着它一次只检查一小块像素区域;这类似于人类用放大镜查看图片时,聚焦于某些细节区域(只不过在此情况下像素没有变形)。get_front_face_detector 函数的输出是一个 (``index``, ``rectangle_coordinates``) 元组列表,每个元组对应一个检测到的人脸。我们将这些信息存储在一个名为 detector 的变量中,供预测器帮助聚焦于人脸。实际的面部特征位置由形状预测器处理,该预测器接收包含某个物体(在本例中是人脸)的图像区域,并输出一组定义物体姿态的点位置。为了加载预测器,我们告诉 dlib 我们感兴趣的模型路径,在本例中是 facial_model/shape_predictor_68_face_landmarks.dat

在定义了面部检测器和关键点检测器组件后,我们可以开始处理图像了。对于面部识别系统(以及其他任何预测算法)来说,确保测试图像与训练图像的处理方式完全相同是非常重要的;否则,处理方式的差异可能会以不可预测的方式破坏结果。接下来的部分将描述一段模块化的代码,我们可以在概念验证中使用它来处理训练数据的创建和测试图像的处理,这两个过程独立于建模功能。

处理图像数据

处理图像数据的方法有很多种,这样预测算法就能基于其信息构建模型;然而,它们都有一个共同的目标,那就是将数据转换为标准化格式。你打算如何预测人脸,决定了在将图片转换为特征集之前,是否需要进行某些处理步骤。例如,你可以选择通过为每个颜色通道(红色、绿色和蓝色)分别创建预测器来保留颜色信息,这样的话你就不需要像这里一样将图像转换为灰度图像。无论最终的处理计划如何,一些操作都是相当常见的。诸如将图像裁剪为面部区域或调整图像大小等操作有助于确保所有样本一致地缩放,并且特征最终会在一定程度上标准化。清单 10-2 显示了处理 .jpeg 文件格式图像的函数。

import cv2
import imutils
def process_jpg(file_path):
    img = cv2.imread(file_path)
    image = imutils.resize(img, width=300)
    return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

清单 10-2:处理单个 .jpeg 图像的函数

我们首先定义了 process_jpg 函数。我们需要的唯一参数是 .jpeg 图像的路径,存储在 file_path 中。我们使用 cv2(计算机视觉 2 的缩写)库的 imread 函数将文件读取为一个数据数组,表示每个颜色通道的像素值。然后,我们使用 imutils.resize 函数调整图像数据的大小。我们将图像缩放到宽度为 300 像素,使用 width 参数;图像的高度会根据新的宽度计算,以避免扭曲特征。最后,我们使用 cv2.cvtColor 函数将调整大小后的图像数据转换为灰度图像,并返回结果。(我选择将图像数据转换为灰度图像,因为颜色信息不会帮助我们进行几何分析。)

图 10-3 显示了一个示例结果。

图 10-3:处理过的面部图像,准备进行分析

你可以看到,我们返回的是宽 300 像素、高 450 像素的灰度图像。注意,面部特征没有被扭曲;这是因为我们使用的缩放方法。我们将使用这两张图像来举例说明接下来的过程。两张图像的对比度都不错,面部特征没有被遮挡(如墨镜、帽子或浓妆),因此它们是很好的候选图像。同时,两张图像也有一些区域最终会证明对算法来说更具挑战性,正如你所看到的那样。

在我们继续处理图像之前,通过调整图像的大小并将其转换为灰度图像,可以确保我们获得一致且可重复的样本,这样就能避免图像质量、比例和光照等方面的细微差异。这个步骤只是开始。你应该考虑应用其他图像转换(例如增加亮度或调整对比度)来为算法提供最佳的分析成功机会。在下一部分,我们将处理过的图像带入实际操作,开始定位和分析面部特征。

定位面部标志

现在我们已经将图像处理步骤定义为一个函数,可以在面部标志定位代码的开头调用它,以确保我们处理的是调整大小后的灰度图像。接下来的任务,见清单 10-3,是编写一个函数,定位我们将用于定义面部其余结构的面部标志。

from imutils import face_utils
def locate_landmarks(image_file):
  ❶ gray = process_jpg(image_file)
  ❷ clone = gray.copy()
  ❸ rects = detector(clone, 1)
    feature_coordinates = {}
 ❹ for (i, rect) in enumerate(rects):
      ❺ shape = predictor(clone, rect)
      ❻ shape = shape_to_np(shape) # See PoC code
      ❼ for (part_name, (i, j)) in face_utils.FACIAL_LANDMARKS_IDXS.items():
            if len(rects) >= 2:
                feature_coordinates[part_name] = []
            for x, y in shape[i:j]:
              ❽ feature_coordinates[part_name].append((x, y))
        face_points = []
        for n in feature_coordinates.keys():
          ❾ face_points += feature_coordinates[n]
      ❿ feature_coordinates[part_name] = face_points
    return feature_coordinates

清单 10-3:在图像中定位特征标志

locate_landmarks 函数只接受一个文件路径作为参数。然后,我们通过调用清单 10-2 中的process_jpg函数,从文件参数获取处理后的图像❶。我更喜欢在图像的副本上工作❷,以避免不小心用修改后的图像覆盖原图。复制图像后,我们使用在清单 10-1 中创建的detector来定位输入中每张脸的矩形区域❸。结果是一个包含面部索引和对应矩形区域坐标的元组列表。对于示例数据,列表中将只有一个矩形,但你可以扩展该函数以处理未来项目中多个面部的情况。

接下来,我们遍历矩形列表❹,并将每个矩形传递给在清单 10-1 中设置的predictor❺。结果是一个包含点坐标的列表,按照图 10-1 中所示的顺序排列。我们使用shape_to_np函数❻,这是一个简单的辅助函数,用于将形状的(x, y)坐标转换为 NumPy 数组。

def shape_to_np(shape, dtype="int"):
    coords = np.zeros((68, 2), dtype=dtype)
    for i in range(0, 68):
        coords[i] = (shape.part(i).x, shape.part(i).y)
    return coords 

在这里,我们创建一个名为coords的零数组,它是一个 NumPy 数组,用于存储 68 个坐标对。接下来,我们遍历shape.part列表中的所有索引。对于每个部分,我们从xy属性创建一个元组,并将该元组分配到coords数组的相应索引中。一旦收集完所有坐标对,我们返回包含这些元组的数组。

在清单 10-3 中,为了方便后续操作,我们构建了一个以特征名称为键的字典,字典中包含定义该特征的点列表。face_utils.FACIAL_LANDMARKS_IDXS.items函数❼返回一个包含元组的列表,该元组提供了特征名称和定义shape中对应特征的起始和结束索引的嵌套元组。我们遍历这些定义,并在feature_coordinates字典中创建相应的条目❽。如果图像中有多张人脸,面部的索引编号将附加到特征名称后面,以便区分它们。

接下来,我们创建一个包含所有面部数据点的列表,并将其附加到一个列表中❾;这个列表将在概念验证中用于计算面部的凸包。从形式上讲,凸包是包含一组点的最小凸多边形,使得该集合中的每个点都位于该多边形内部或其边界上。记得在第七章中提到,凸多边形是所有内部角小于 180 度的多边形。你可以将凸包想象成如果你把一根橡皮筋拉紧围绕所有点的外部,你所得到的结果。

最后,我们将生成的列表添加到feature_coordinates字典中❿。

图 10-4 展示了对我们两张测试图像运行算法的结果。

图 10-4:地标检测结果

灰色的点表示算法认为每个面部特征的位置。整个面部区域的黑色轮廓表示生成的凸包。如你所见,程序做得相当不错。大部分情况下,点正确地定位了我们要寻找的地标。程序表现不佳的地方是无法找到左图中女性的下巴线,因为她的深色头发与背景的对比度比下巴线与背景的对比度更加明显。她面部的测量值会出现偏差,除非每次偏差的方式完全相同,否则会导致不良的训练数据。

尽管这个人的下巴被胡须遮住,算法还是更好地找到了下巴线。但算法未能正确找到鼻子的边缘。虽然与实际位置之间的差异相当小,而且虽然这会影响一些测量值,但整体面部形状保持了比例,因此生成的训练数据仍然可用。在进行这些测试并检查结果几个月后,你将能够查看一张图像,并大致估算出根据你定义的处理步骤,地标检测器的表现如何。

在调用locate_landmarks函数后,你应该会得到一个以特征名称为键的字典,这样你就可以使用人类友好的名称来引用点集。例如,要创建表示左右眼的多边形,可以使用以下代码:

from shapely import Polygon
leye = Polygon(feature_coordinates["left_eye"])
reye = Polygon(feature_coordinates["right_eye"])

然后你可以使用 Shapely 来测量形状之间的最小距离:

dist = reye.distance(leye)

或者,你可以测量两只眼睛之间区域的差异:

diff = abs(reye.area – leye.area)

你可以使用许多其他潜在的特征,但我们将在下一节开始构建训练数据集时进一步讨论这些内容。现在我们已经定义了所需的功能,可以开始构建面部识别系统的概念验证部分。

概念验证:开发面部识别系统

这个项目的概念验证分为两部分。第一部分从面部图像集构建训练数据。在这里,我们将为每张图像做准备,并定义需要收集的统计数据。这正是计算几何学能帮助我们的地方。图像处理步骤可能需要几分钟时间(如果数据集更大,可能会更长),因此将这些计算单独处理并将结果存储到文件中,供以后处理是有意义的。这样可以避免反复运行昂贵的计算。它还使得向数据集中添加新图像变得更容易。我们无需重建整个数据集来重新训练模型,只需要在重新训练前处理新图像即可。请注意:试图在内存中处理所有这些图像是不可行的。我们需要逐个处理文件,以便保持内存使用在可控范围内。你将在本章后面看到如何操作。

概念验证的第二部分定义了机器学习算法,并使用先前计算的统计数据进行训练。我们将使用一种称为留一法(LOO)的交叉验证过程多次测试该算法。在每个数据类上运行一次验证,LOO 算法从该类中选择一张图像,将其从训练数据中排除(因此得名)。然后,模型将在剩余的数据上进行训练。训练完成后,我们将给它选定的图像进行分类,并计算每个类别的结果,以估算整体表现。LOO 验证方法的主要优势在于,它为训练算法提供了最多的信息,因为在训练前只移除一个实例。由于每个人脸的图像数量有限,我们需要给训练算法尽可能好的成功机会。

面部统计数据

概念验证的第一部分位于facial_recognition_poc_1.py文件中。它包含了你迄今为止所看到的代码,但我们会对其进行扩展,创建最终的训练数据集。关于面部结构的统计信息收集,你可以采取多种方法。我最初的尝试是以不同的方式网格化面部,并衡量不同部分的预测能力。图 10-5 显示了最佳得分的方法,并将其应用于两个示例面部。

图 10-5:自动面部网格化的结果

网格化将鼻子、眼睛和口腔内部部分视为主面部多边形中的孔。这种方法的主要问题是,许多相似的三角形并未对结构知识提供任何贡献(例如,构成下巴的所有三角形);这导致所有面部的相似变量数量过多。另一个问题是,网格化并没有包含所有感兴趣的形状。例如,从两个眼睛的外侧点和下巴底部创建三角形将显示整个头部的倾斜。既然这种方法不起作用,我为什么还要告诉你呢?因为必须认识到,试错是必需的。思考方法失败的原因,可能比思考它为何有效更具启发性!

在我的研究中,我发现了一篇来自面部识别科学工作组(FISWG)的论文,出色地描述了标准面部统计数据。^(4) 最终,我将策略从自动网格化改为明确地定义 62 个大多来自参考资料的测量值。为了帮助在代码中定义这些测量,您可以创建一些变量来按名称表示关键点。例如:

nose_btm = feature_coordinates["nose"][6]
bridge_top = feature_coordinates["nose"][0]
upper_lip_ctr = feature_coordinates["mouth"][3]
lower_lip_ctr = feature_coordinates["mouth"][9]
chin_ctr = feature_coordinates["jaw"][8]
r_temple = feature_coordinates["jaw"][0]
l_temple = feature_coordinates["jaw"][16]

你可以使用这些点以某种方式创建测量,使得几个月后重新回到代码时,仍然能够理解哪些变量与面部的哪些点相关。清单 10-4 展示了概念验证中的度量样本。

from shapely import LineString
face_dict = {}
❶ face_dict["tri_area"] = Polygon([r_temple, chin_ctr, l_temple]).area
❷ face_dict["face_vert"] = LineString((chin_ctr, bridge_top)).length
face_dict["bow"] = LineString((upper_lip_ctr, nose_btm)).length
❸ face_dict["bow_ratio"] = face_dict["face_vert"]/face_dict["bow"]

清单 10-4:使用 Shapely 定义几何统计数据

我们收集的三大类统计数据分别是面积、距离和比率。面积度量将一组面部关键点转换为一个多边形对象,然后记录该形状的area属性❶。距离度量从两个或多个点创建一个LineString对象,然后记录其length属性❷。比率是派生的度量,比较两个之前创建的度量值。例如,在这里我们比较了上唇到鼻底之间的线长(俗称“丘比特之弓”)与面部的总垂直高度(从下巴到鼻顶的高度)❸。比率应当仅比较相同类型的统计数据。两个面积的比率或两个距离的比率是有意义的,但距离与面积的比率在这个上下文中没有多大意义。

除了 62 个显式度量外,我还将 x 坐标和 y 坐标作为单独的特征包含在数据中,每张图像共计 214 个数据点。我们将让模型构建算法在本章的“特征工程”部分决定哪些特征最具信息量,但现在,定义更多的统计数据意味着有更高的机会发现一些有意义的特征。

内存管理

如前所述,尝试在内存中处理所有这些图像并不可行。图像包含大量信息,试图同时打开大量图像进行处理会迅速填满内存缓冲区。相反,概念验证一次只打开一张图像并计算所有统计数据。它保存数据并立即关闭图像,然后转到下一张图像。列表 10-5 展示了循环的结构。

❶ image_paths = get_image_files("faces95", image_paths)
face_collection = []
for image_file in image_paths:
  ❷ feature_coordinates = locate_landmarks(image_file)
  ❸ if len(feature_coordinates.keys()) < 8:
        continue
 `--snip--`
  ❹ face_series = pd.Series(face_dict)
    face_collection.append(face_series)
❺ faces_df = pd.DataFrame(face_collection)
❻ faces_df.to_csv("facial_geometry.csv")

列表 10-5:遍历图像文件进行处理

get_image_files函数是概念验证中的另一个辅助函数,它递归地遍历目录结构,收集所有文件名,排除以.txt结尾的文件❶。完成这一操作后,我们会遍历结果列表中的每个文件路径,并将其传递给我们在列表 10-3 中定义的locate_landmarks函数❷。数据集中的一些图像不够清晰,导致地标检测器无法找到所有特征。在这种情况下,特征字典将没有正确数量的键,我们可以跳过任何进一步的处理❸。该片段是我们将在列表 10-4 中展示的方法中添加所有面部统计信息的地方。

一旦所有数据点创建完成,我们将字典转换为 pandas Series 对象 ❹,并将其附加到人脸集合中。所有图像处理完成后,我们从 Series 对象列表中创建一个 DataFrame ❺。最后,我们将结果保存到 .csv 文件中,以供以后使用 ❻。运行脚本将通过创建一个由几何统计数据派生的数据集来结束概念验证的第一部分。我们将使用这些数据来训练接下来两部分中开发的分类器。

数据加载

到此为止,我们已经创建了几何数据集并将其保存为 .csv 文件。概念验证的第二阶段位于文件 face_recognition_poc_2.py 中,从加载之前创建的数据到 pandas 开始。示例 10-6 展示了如何加载 facial_geometry.csv 并为关联计算准备数据。

import pandas as pd
❶ faces_df = pd.read_csv("facial_geometry.csv")
❷ faces_df.drop(["Unnamed: 0", "file"], inplace=True, axis=1)
❸ faces_df["category"] = faces_df["name"].astype("category")
❹ cat_columns = faces_df.select_dtypes(["category"]).columns
❺ faces_df[cat_columns] = faces_df[cat_columns].apply(lambda x: x.cat.codes)
❻ name_map = faces_df[["name", "category"]].set_index(["category"])

示例 10-6:为训练准备 facial_geometry.csv 数据

我们首先调用 pandas 的 read_csv 函数来获取上一阶段的数据 ❶。文件创建后会生成一个 Unnamed 索引行,我们将其删除以节省内存 ❷。下一步是定义 category 变量。name 字段包含一个随机分配的假名,以使数据看起来真实,同时仍然保护数据主体的隐私。这一列是我们感兴趣的预测目标,但 pandas 默认将其视为文本字符串,因此我们使用 astype 函数将其转换为 category 类型的分类列 ❸。我们使用 select_dtypes 函数收集所有分类列的名称。结果是 faces_df 中具有 category 类型的列名列表 ❹。目前 category 列应该是唯一的结果,但通过这种方式引用所有分类变量会更加方便,如果将来你希望添加更多分类信息。

因为 pandas 会自动为从分类列派生的每个类别分配一个数字索引,我们使用 apply 函数用该数字索引覆盖 category 列的内容 ❺。为了方便起见,我们创建了一个查找表,以便通过复制 faces_df 中的 namecategory 列并将它们保存到另一个 DataFrame 对象 name_map 中来转换类别 ID 与名称之间的映射 ❻。

这就是我们的数据加载代码的全部内容。此时,我们已经加载了之前创建的facial_geometry.csv文件,并将类别(即主题名称)转换为 pandas 能够理解的格式。下一步是保留一个真实保留集,即在特征工程、训练和性能评估阶段从未使用过的数据实例。我选择了三个实例作为保留集的大小,以便模型训练部分有足够的数据来学习模型。机器学习的一个主要问题是研究人员不小心让算法直接或间接访问测试数据的答案;这样会使模型产生偏见,导致它在测试集上表现优秀,但在实际应用中可能会严重失败。

举个例子,假设所有的主题都有 20 张图片,除了为真实保留集保留的三个主题,它们在数据中只剩下 19 张图片。如果算法可以访问每个主题的图片数量,它就能将列表缩小到这三个主题,尽管这些信息在生产系统中可能不适用,因为主题的照片数量可能不同。因此,为了确保我们的结果没有被污染,算法将从不同类别中保留三个样本(来自三个人的各一张照片),以测试最终模型。使用真实保留集相当于测试三张从未见过的照片,这与生产需求的测试非常接近。

在列表 10-7 中,我们创建了三实例的保留集。

from random import choice
real_test = {}
while len(real_test) < 3:
  ❶ name = choice(list(faces_df["name"].unique()))
    if name not in real_test.keys():
      ❷ group = faces_df[faces_df["name"] == name]
      ❸ real_test[name] = choice(group.index.to_list())
❹ index_list = [r[1] for r in real_test.items()]
❺ real_X = faces_df.iloc[index_list]
❻ faces_df.drop(index_list, inplace=True)

列表 10-7:随机选择一个真实的保留数据集

我们使用choice函数从唯一名称的列表❶中随机选择一个主题名称。对于真实数据,最好通过 ID 来做,因为在公司数据集中,两个人有相同名字的概率相当高。幸运的是,我们在样本数据中不需要担心这个问题,因为这些名字是使用 faker(一个生成看起来真实数据的库)随机生成的,因此名字相同的概率要低得多。如果选中的主题已经在保留集中了,我们会继续随机选择名称,直到找到一个不在保留集中的主题。

接下来,我们为随机选择的主题❷收集所有实例。我们再次使用choice函数从实例组❸中选择一个随机的实例索引。结果是一个以主题名称为键的字典,值表示随机选择的实例索引。一旦收集到索引,我们使用列表推导式从字典中收集所有的索引❹。然后,我们将实际的实例数据从faces_df对象复制到一个单独的DataFrame❺。最后,我们从faces_df中删除这些实例,以便它们不会在关联矩阵计算中被使用❻。

特征工程

现在图像处理已完成,接下来是进行实际的模型训练代码。对于这个概念验证部分,我们将应用特征工程和机器学习到我们之前生成的面部数据中。我们的目标是生成一个预测模型,能够根据以前未处理的包含面部的图像来识别主体,该图像是我们之前分析过几何形状的(使用在清单 10-7 中创建的保留集)。为了实现这一目标,我们需要从数据集中去除多余的噪音,以便我们的算法可以专注于真正有用的测量。这就是特征工程的作用。

在任何机器学习项目中,特征工程是最重要的步骤之一,它涉及对数据中不同变量与我们希望预测的类别值之间的关系进行数学分析,以确定哪些变量能提供最有用的信息。你能预测到有用信息的能力,直接取决于可用数据的质量和数量。如今,数据不足几乎不再是问题。相反,我们通常拥有关于某个主题的大量数据,人工判断哪些数据对结果真正重要几乎是不可能的。重要的关系可能会被无用数据的噪音淹没。为了解决这个问题,研究人员使用一个或多个特征工程算法,根据特征对我们希望预测的某个值(在本例中是与面部相关的主体名称)的贡献来评分特征。我们将应用三个步骤,逐步缩小特征集,仅保留那些我们确信对模型准确性有贡献的特征。

关联矩阵

一种流行的特征评分方法是关联矩阵,它用于确定在一大列表中特征之间的相关性。运行关联算法的结果是一个n × n 的矩阵,其中n是数据中特征的数量。每个单元格包含由列和行定义的两个特征之间的相关性评分。图 10-6 展示了面部数据的部分关联矩阵。

图 10-6:特征关联矩阵

变量与自身之间的相关性始终为 1.0,因此可以忽略这些实例。我们最感兴趣的是那些与我们希望预测的特征具有高度相关性的特征。请注意,相关性是以绝对值计算的,这样负相关和正相关就能被视为同等重要。将高度相关的变量一起考虑,提供了最佳的机会来正确预测感兴趣的值。

正常的相关性方法的局限性在于,你必须关联一些连续的(实数)数值。在这种情况下,我们希望测量连续变量与离散的类别变量(一个主体的名字)之间的相关性,因此标准的相关性度量方法不起作用。相反,我们使用了一种名为 Theil’s U 的相关性评分来计算矩阵,它能够处理类别数据,代码来自 Shaked Zychlinski 的博客文章(towardsdatascience.com/the-search-for-categorical-correlation-a1cf7f1888c9)。所有的函数都位于nominal.py文件中,并附有作者原始的操作说明,因此我将重点介绍如何将association函数集成到我们的面部识别系统中。

清单 10-8 展示了我们如何计算关联矩阵。

import nominal
assoc_matrix = associations(
    faces_df,
    nominal_columns=cat_columns,
    theil_u=True,
    return_results=True
)

清单 10-8:计算特征集的关联矩阵

来自nominal.py文件的association函数接受一个DataFrame对象来进行计算;在这里就是faces_df。你需要传入一个类别列的列表。此时,之前在清单 10-6 中定义的cat_columns变量派上了用场;即使我们添加了更多的类别信息,也不需要编辑代码。为了使用 Theil’s U 进行计算,我们必须将theil_u参数设置为True。默认情况下,association函数只是将结果显示到屏幕上,但我们希望使用这些数据来程序化地选择用于模型的特征,因此我们将return_results参数设置为True,以便将结果也作为矩阵返回。

现在,我们已经为每个特征计算了关联分数,可以将最高的预测因子(那些与category列具有较高关联分数的特征)收集到一个列表中,这样我们可以将结果与接下来的两个特征工程步骤进行比较。一种迭代的方法是从 10 个表现最好的特征开始,看看是否可以训练出一个有效的模型。继续每次增加 5 到 10 个特征,直到找到产生可靠模型的最小特征数量。另一种方法(也是我偏好的方法)是为你想保留的特征设置一个预测阈值,具体方法如下:

assoc_matrix = assoc_matrix[abs(assoc_matrix["name"]) > 0.95]
key_features = [k for k in assoc_matrix["name"].index]

选择去除特征的标准既是艺术也是科学。经过一些反复试验后,我发现可以保留那些得分高于 0.95 的特征,并且最终模型依然表现良好。key_features变量包含了与name列相关性分数大于 0.95 的 19 个列名;这包括namecategory列,它们的分数为 1.0,正如我之前提到的。

然而,关联矩阵仅仅是预测能力的一个指标。为了真正确定我们选择的是最佳特征集,我们将运行另一个特征选择算法,并比较两个算法中表现最好的特征,查看哪些特征出现在两个列表中。这些特征将有很大的机会提高我们预测的准确性。

互信息分类

如果一种关联度量很好,那两种应该更好,对吧?在这种情况下,我们可以应用第二种特征排名方法,以便获得更多的洞察,帮助我们找出哪些特征最有用。互信息(MI)分数是一个非负值,衡量特征之间的依赖关系。当两个随机变量完全独立时,其值为 0。较高的值表示较高的依赖性。^(5) 清单 10-9 展示了如何使用 scikit-learn 计算 MI。

from sklearn.feature_selection import mutual_info_classif
contributing = mutual_info_classif(
    faces_df.drop(["name", "category"], axis=1),
    faces_df["category"],
    discrete_features="auto",
    n_neighbors=7
)

清单 10-9:计算每个特征的 MI 贡献

第一个参数是我们想要计算 MI 分数的特征矩阵。为了避免污染数据,我们通过内联调用 drop 函数从特征集中删除 namecategory 特征,这不会从实际数据中删除列,只是删除传入算法的临时特征列表。下一个参数是实例类别列表,用于内部训练分类器;我们传递 faces_df["category"],因为它包含我们感兴趣的类别数据,我们希望找到与之相关的 MI 分数。您可以将 discrete_features 参数设置为一个特征标签列表,以显式将其视为离散值,或者像我们在这里做的那样,将其设置为 auto,让算法尝试自动检测离散特征。计算结果是使用最近邻分类器得出的,因此可能需要一些试错来找到适合的邻居数量。迭代方法可以帮助您找到该参数的最佳设置。经过几次测试,我选择了七个邻居。调用 mutual_info_classif 的结果是一个与 faces_df 数据中的列顺序相同的值列表。

如之前所述,我们可以通过选择所有 MI 分数大于 1 的特征来收集表现最好的特征。然后,我们将其与通过关联矩阵生成的关键特征列表进行比较,从而创建一个更加精简的特征列表,这些特征在预测 category 列的值时非常有用。清单 10-10 展示了如何进行操作。

results = zip(
    faces_df.drop(["name", "category"], axis=1).columns,
    contributing
)
mi_scores = [f for f,v in results if v >= 1]
reduced_features = [k for k in mi_scores if k in key_features]

清单 10-10:查找最佳特征列表之间的重叠

zip 函数将数据中的列名与我们在示例 10-9 中定义的 contributing 变量的结果结合起来,创建一个元组列表,结构为 (``列名``, MI 得分``)。然后,我们使用列表推导式将结果过滤为一个列名列表,列名的得分大于或等于 1.0。最后,我们将 key_features 列表中的列名与 mi_scores 列表中的列进行比较。任何在这两个列表中的列都会进入 reduced_features 列表,这代表了那些在关联性和互信息方面对我们想要预测的分类变量都得分良好的特征。此时,应该只剩下九个列,因此如果我们愿意,可以在这里停止。我们将数据集从超过 200 个特征减少到仅剩 9 个。在实践中,你可能能依赖这些特征进行可靠的建模,但我喜欢稍微挑战一下——看看我们能做到多极端。我们再做一次特征工程,看看是否能进一步集中预测能力。

相关性比率

在统计学中,相关性比率是衡量个体类别内部统计分布(在本例中为同一个人照片的几何描述)与整个样本或总体分布(数据集中所有照片的几何描述)之间关系的一个度量。该度量被定义为代表每种变化的两个标准差的比率,或者是一个特征在类别内的变异性与其在整个数据集中的变异性的比率。理想的特征应该在单一类别内有较低的方差,但在类别之间有较高的方差。

直观来说,这与我们想要具有对一个人一致性,但在不同人之间有所差异的特征是一样的。一个例子是眼睛外部点之间的距离。对于一个人来说,我们期望这个测量值相对一致,但我们期望两个不同人之间的测量值会有更大的差异。

示例 10-11 展示了如何为 faces_df 数据计算相关性比率。

etas = {}
for feat in faces_df.columns.to_list():
    if feat not in ["category", "name"]:
      ❶ etas[feat] = correlation_ratio(faces_df[feat], faces_df["category"])
❷ sorted_rank = sorted(etas.items(), key=lambda kv: kv[1])
reduced_key_features = []
for f in sorted_rank[-21:]:
  ❸ if f[0] in reduced_features:
        reduced_key_features.append(f[0])

示例 10-11:计算特征的相关性比率

correlation_ratio 函数❶也位于nominal.py文件中。它接受一个Series对象,表示我们想要评分的特征,以及另一个Series对象,表示分类特征。其值在 0 到 1 之间的实数范围内,0 表示无法通过特征的测量来确定类别,而 1 表示可以完全确定类别。我们将结果值赋给一个以列名为键的字典。一旦所有特征都被评分,我们使用sorted函数对字典进行排序。结果是一个元组列表,结构为(``列名``, 值``),按从最差到最好进行排序❷。我们遍历最后 21 个条目,将每个列名与reduced_features列表进行比较。如果某个列名同时出现在两个列表中,它就会被放入最终的特征集reduced_key_features中❸。结果是数据中四个最具预测能力的特征列表:

['outer_eyes', 'nose_area', 'face_horz', 'center_tri_area']

这些特征在所有三种特征选择方法中都是表现最好的特征,强烈表明它们具有分类数据的能力。在接下来的步骤中,我们只会使用这四个特征来训练我们的模型。在生产系统中,您可以利用这些信息来减少第一阶段收集的统计数据的数量。显然,我在第一阶段定义的大多数测量数据对于区分数据中的面孔并不必要,但刚开始时你通常无法知道哪些是有用的,因此收集大量数据点并让算法完成工作可以揭示出意想不到的关系。需要特别注意的是,这些特征是针对这个数据集的。你不能仅仅在一个图像集上进行特征选择,并期望这些特征能够完美地转移到其他任何图像数据集上。

模型训练

现在,数据终于准备好进行建模了。我们将使用在特征工程中选出的四个特征创建减少后的数据集。然后,我们通过对数据进行简单分类器评分来建立零假设。最后,我们将使用随机森林分类器构建真正的模型,进行最初从一开始就保留的三个图像的最终分类。

数据拆分

第一步是将数据拆分为训练集和测试集。Listing 10-12 定义了这两个数据集以及处理测试数据生成的对象。

from sklearn.model_selection import LeaveOneOut
X = faces_df[reduced_key_features]
y = faces_df["category"]
loo = LeaveOneOut()
splits = list(loo.split(X))

Listing 10-12: 创建训练集和测试集拆分

传统上,变量X用于表示测试数据(其中不包含分类信息)。在这里,我们只取faces_df数据中定义在reduced_key_features中的特征子集来定义X。我们使用变量y来保存来自category列的相应类别信息。最后,我们使用LeaveOneOut类中的split方法创建X数据的n个副本(其中n是不同类别的数量)。结果是一个包含形式为(``训练索引``, 测试索引``)的元组列表,称为splits。在 LOO 验证方案中,每个拆分都会从数据集中移除不同的图像进行测试,因此测试索引将始终包含一个单一的实例 ID,而训练索引将包含其余的部分。如前所述,LOO 验证方法为算法提供了最多的训练信息。它也更接近系统在生产中使用的方式,其中会将一个主体面部的图像与面部数据库进行比较。

建立基线

为了建立基线得分,我们首先建模一个或多个虚拟分类器,这些分类器使用非常简单的预测方法(如随机猜测或猜测多数类)来建立一个最差情况的性能得分。示例 10-13 展示了与 scikit-learn 分类器一起工作的 API。

from sklearn.dummy import DummyClassifier
from sklearn.model_selection import cross_val_score
❶ dc = DummyClassifier(strategy="uniform")
scores = []
hits = 0
misses = 0
for train_index, test_index in splits:
  ❷ X_train, y_train, = X.iloc[train_index], y.iloc[train_index]
  ❸ X_test, y_test, = X.iloc[test_index], y.iloc[test_index]  
  ❹ cvs = cross_val_score(dc, X_train, y_train, cv=4)
  ❺ score = sum(cvs) / 4 # Default cv value
    scores.append(score)
  ❻ dc.fit(X_train, y_train)
  ❼ y_pred = dc.predict(X_test)
  ❽ if y_test.values[0] == y_pred:
        hits += 1
    else:
        misses += 1
❾ print((sum(scores) / len(scores))*100)
❿ print((hits / (hits+misses))*100)

示例 10-13:训练一个基线虚拟分类器

我们使用来自 scikit-learn 的DummyClassifier类(它与实际分类器具有相同的 API)来定义基线模型。传递uniform参数❶会创建一个将随机从可能类别集中猜测类别的模型。我们遍历之前定义的拆分,以从相应的索引列表中创建训练和测试实例。X_trainy_train变量❷用于训练模型(如果模型需要训练的话),而X_testy_test变量❸则用于对结果模型进行评分。

我们使用cross_val_score函数❹来获取拆分的先验性能估计。该函数接受一个分类器对象和训练数据集的两个部分。cv参数设置用于验证模型的折数。默认是五折,但数据中包含一个只有四张图片的类别,因此如果我们不将其设置为4,Python 将输出一堆警告。该函数使用传入的分类器对象执行四折交叉验证。为了获取四个折中的平均得分,我们将得分相加并除以折数❺。我们将平均得分保存到一个列表中,以便在循环完成后进行分析。

接下来,我们使用训练数据❻来拟合DummyClassifier对象。拟合分类器是对模型进行训练的正确术语。当我们为我们的虚拟分类器执行此操作时,内部没有发生任何事情,但在下一步使用更复杂的分类器时,这是正确的工作流程,因此最好遵循这一惯例。

最后,我们使用拟合后的分类器来预测来自y_test数据集❼的结果,这是 LOO 算法保留的那张图片。predict函数的结果是算法认为数据属于的类别。我们将预测类别与实际类别❽进行比较,并相应地增加命中或失误的计数。

一旦循环完成,我们通过将scores列表的所有元素相加,并除以列表的长度来计算平均交叉验证得分❾。结果是交叉验证的平均得分的平均值,这是一个不错的现实世界表现指标。在我的测试中,DummyClassifier的准确率大约为 0.4%。为了验证性能估计,我们还计算命中与失误的比率❿。在我的测试中,实际得分略低于 0.6%(在 4,457 次机会中命中了 26 次)。这个结果作为我们接下来结论的零假设(如果你不熟悉假设检验,可以查看这篇文章:www.statisticshowto.com/probability-and-statistics/hypothesis-testing)。如果我们的实际分类器能够做到比 0.6%正确分类更好,那么模型在正确性方面就超出了随机巧合的范围。

实现随机森林

现在是时候实现随机森林分类器了。由于所有 scikit-learn 分类器共享相同的 API,因此代码在清单 10-13 中的虚拟分类器代码基础上几乎没有变化。清单 10-14 展示了变化部分。

from sklearn.ensemble import RandomForestClassifier
from random import randint
❶ rfc = RandomForestClassifier(
    n_estimators=100, min_samples_split=5, min_samples_leaf=3
)
❷ for i in range(50):
    split_i = randint(0, len(splits))
  ❸ while split_i in chose:
        split_i = randint(0, len(splits))
    chose.append(split_i)
`--snip--`

清单 10-14:决策树算法定义

我们不再定义虚拟分类器,而是使用来自 scikit-learn 的ensemble模块中的RandomForestClassifier类❶。集成分类器内部使用多个分类器,并将它们的预测聚合为单一的预测。在这种情况下,每个内部分类器都是通过对输入数据进行随机采样训练的随机树——因此叫做随机森林

n_estimators参数定义了要训练的内部分类器数量。min_samples_split参数定义了用于训练内部分类器的最小实例数。min_samples_leaf参数告诉随机森林,在考虑一个有效叶节点时,至少需要多少样本。将该参数设置为较高值将开始自动修剪决策树中不太有用的逻辑分支。如果你回顾一下图 10-1 中的决策树,你会看到树的最底部的叶节点每个只有一个样本。由于数据集本身就很小,这样是可以的,但如果叶节点的样本数很低,而数据量又很大,那很可能意味着该逻辑分支并没有比其他覆盖更多样本的叶节点提供更多的信息。你可以通过手动或自动参数调整来找到一个最优配置。

第二个改动是使用randint函数来随机选择拆分❷,而不是按顺序遍历所有拆分。我选择了 50 个随机拆分,并没有什么特殊原因;我鼓励你找一个更合适的数字。我们使用while循环确保该拆分没有被用来训练随机森林❸。

从这一点开始,代码与之前的虚拟分类代码相同(你可以对比Facial_Recognition_notebook2_Modeling.ipynb笔记本中的第 23 和 25 行来看这一点)。只需确保将dc对象引用重命名为rfc。这样,代码就应该准备好运行了。我的测试结果给出了五倍交叉验证估算的性能为 76.5%,测试集的表现为 72%(50 次机会中有 36 次正确)。虽然 72%的准确率看起来可能并不是特别好,但考虑到我们仅使用四个几何特征来预测正确结果,这已经相当令人印象深刻了!

测试保留图像

对于模型的最终验证,我们将给它三个真实的保留图像,看看它是否能够预测正确的对象。记住,这些图像到目前为止没有被任何代码部分使用过,因此对模型来说是完全新的。考虑到之前 72%的准确率结果,合理的猜测是结果应该是三个可能结果中有两个或三个正确。清单 10-15 展示了我们如何在保留图像上运行训练好的分类器。

real_y = real_X["category"]
test_X = real_X[reduced_key_features]
rfc = RandomForestClassifier(
    n_estimators=100, min_samples_split=5, min_samples_leaf=3, random_state=42
)
rfc.fit(X, y)
y_pred = rfc.predict(test_X)
print(list(zip(y_pred, real_y)))

清单 10-15:使用真实保留数据进行测试

category列来自real_X数据集,定义了我们希望预测的正确类别。我们通过从real_X数据集中提取reduced_key_features子集来定义test_X数据。然后,我们像之前一样创建RandomForestClassifier对象。当我们拟合模型时,我们使用的是整个数据集,但不包括真实的保留集。然后,我们在test_X数据上调用predict函数。结果是一个类索引的列表,应该与real_y列表中的三个索引匹配。为了方便比较,我们可以使用zip函数将这两个列表结合并打印出来。

这是我在写这个过程时的测试结果:

[(25, 25), (122, 122), (174, 174)]

完美的得分!需要注意的是,由于算法的随机性,你的结果在不同的运行中会有所不同。我的测试中的虚拟分类器得分在 0.2%到 0.6%之间,而RandomForestClassifier的得分通常超过 72%。如果你得到一个奇怪的结果,比如保留集没有正确分类的情况,试着重新运行代码。以 72%的期望精度来看,仍然有 28%的概率一个保留图像会被错误分类,有 7.8%的概率两个会被错误分类(0.28² = 0.078),而且有 2.2%的概率算法会错误分类所有三个保留图像(0.28³ = 0.022)。

到目前为止,我们已经证明了我们的概念是可行的。你可以优化这个过程,以提高算法的准确性和可靠性,但我们已经证明,我们确实可以利用计算几何学来构建一个可用的面部识别系统。显然,结果并非偶然,因为基准分类器的表现极其糟糕,所以我们已经实现了从之前未见过的图像中正确预测三个人物的目标。

除了准确性,你还应该考虑加入预测的置信度度量。我们通过预测单一类别的方式被称为硬分类。它的问题在于,无论是否有理由相信预测准确,你总会得到一个预测结果。你也可以选择使用软分类器,它预测一个测试实例属于数据中任何给定类别的概率。在拟合分类器之后,你可以使用 scikit-learn 的predict_proba函数,替代标准的predict,以获取类似概率的得分列表。通过检查这些得分,你可以了解算法在预测中的“确定程度”。你可以调整随机森林分类器以获得更好的概率得分,并设置阈值置信度来接受或拒绝某一分类。你可以在Facial_Recognition_notebook2_Modeling.ipynb笔记本中,在“软预测”标签下看到使用概率预测的示例。

我们概念验证的最后一步是将我们的工作保存以供未来使用。在下一节中,我们将介绍以适合现代生产环境部署的方式保存和重新加载训练好的模型。

模型持久化

如果每次想对人脸图像进行分类时都必须重新训练模型,那将不切实际。从任何现实的面部数据库中训练这样的模型将需要在单台机器上耗费数小时。幸运的是,我们可以存储训练模型的结果,然后将该保存的状态加载到一个或多个处理应用程序中,而不必直接在数据上重新训练它们。

scikit-learn 的模型持久化文档 (scikit-learn.org/stable/model_persistence.html) 推荐使用一个名为 joblib 的库来处理将数据存储为 pickle 格式。正如你所知道的,pickle 是 Python 最流行的数据序列化和存储库,能够将复杂的 Python 对象存储到磁盘文件中。joblib 库包括两个函数,dumpload,它们是对 pickle 库的便捷封装。以下代码使用 joblib.dump 函数将训练好的模型保存到名为 trained_facial_model.pkl 的文件中:

import joblib
joblib.dump(rfc, "trained_facial_model.pkl")

现在,我们只需调用 joblib.load 函数,就可以将之前训练好的模型加载到另一个程序中:

import joblib
loaded_model = joblib.load("trained_facial_model.pkl")

从这一点开始,你可以像处理之前训练的 rfc 模型一样处理 loaded_model 对象。如果你对 loaded_model 对象运行 type 函数,你会看到 <class 'sklearn.ensemble._forest.RandomForestClassifier'>

将训练好的模型保存为 pickled 对象的一个主要好处是,我们将模型的训练过程与模型的使用过程分开。当我们需要将新数据(例如新人的面部图像)加入到模型中时,我们可以重新运行模型训练代码,而不会中断任何正在进行的分析。从训练过程完成的那一刻起,所有加载该模型的未来代码运行都可以使用更新后的模型,从而实现无缝更新。

最后,关于安全性的一点:众所周知,反序列化一个精心构造的恶意对象可能会导致任意代码执行。由于 joblib.load 函数只是封装了 pickle.load 函数,因此对它来说也是如此。为了降低风险,你绝不应该从不信任的来源反序列化或加载对象。当你在生产中开发一个加载 pickled 模型的应用程序时,必须确保在反序列化模型之前添加某种类型的数据完整性检查。

总结

在这一章中,我们探讨了开发一个功能性面部识别系统所需的所有步骤,结合了我们在计算几何方面的知识和一定的机器学习原理。通过一些调整和进一步的测试,你肯定能够提高系统的性能。通过将这里使用的几何信息与其他非几何分析方法(如色彩图表直方图、小波变换或特征脸)结合起来,你可以将性能提升至 95%以上。一些研究人员报告称,准确率高达 99.96%(截至 2020 年,误差率为 0.04%)。^(6) 这是在美国国家标准与技术研究院(NIST)为面部识别系统供应商评分而开发的测试集上的最佳条件下取得的成绩。

提高准确度和置信度阈值对于你计划将这些面部识别系统应用于安全环境中至关重要。将一个不在数据中的面孔误识别为在数据中的面孔——换句话说,误报——可能导致意外认证一些非常敏感的信息。研究人员在测试条件下证明的另一个潜在安全风险是使用照片或专门设计的面具绕过面部识别系统。^(7) 显然,在这一领域仍然有很多研究和改进的空间。为了继续开发该系统,你可以将所有元素整合到一个处理管道中,使用像 TensorFlow 或 Spark 这样的平台将计算负载分配到多台计算机上,并开始自己解决(或攻击)这些问题。你还可以将这些原理应用到其他图像分类领域,如指纹分析或软件故障分析。

这标志着我们在计算几何领域的冒险之旅结束了。我希望最后三个项目能向你展示它作为工具的灵活性。很多时候,将问题的全部或部分转化为几何表示可以帮助你更好地理解其本质。还有大量的理论等待你自己去探索。通过你在这里学到的基础知识,剩下的部分将变得更加容易理解,并能应用于有意义的安全应用中。

在本书的下一部分“艺术画廊问题”中,我们将再次使用一些计算几何,帮助分析房间的形状和资源的分布,类似于我们如何应用 Voronoi 镶嵌分析波特兰消防站的分布。我们现在就开始吧!

第十三章:分配安全资源以保护空间

本书的其余部分将专注于一个单一且极具实用性的应用。这个经典应用被称为艺术画廊问题,它有大量的研究可以为我们所用,并涉及如何高效地分配安全资源来保护一个空间。效率是当今安全团队的首要任务:保护的资产总是比保护这些资产的资源要多。在其最宏大的形式下,艺术画廊问题将我们所学习的两大主要学科结合起来:图论和计算几何。因此,这一部分也将代表本书迄今为止最完整的 Python 应用,超越了概念设计,进入了完整软件项目的领域。我们将涉及现代 Python 项目的设计、开发和交付选项,包括图形、分布式计算以及如何将你的应用授权给用户。

本书这一部分的目标是开发一个最小可行产品(MVP),它可以被看作是概念验证的一个进阶版本。如你所见,概念验证证明一个想法值得追求,并定义了未来开发的框架。它通常仅限于实现让想法得以起步所需的基本功能——没有任何多余的附加功能。而 MVP 设计则关注你能开发的最少功能集,以便将一个想法推向市场并“具备竞争力”。通常,这意味着添加像图形用户界面(GUI)以及用户友好的元素,如保存和恢复功能。

没有一套确切的功能组合能够使一个应用程序变得可行,因为这些功能最终是由特定市场用户的期望决定的。例如,在安全市场中,用户已经习惯了像单点登录(SSO)、数据加密、推送通知等功能。这是否意味着在推出一个安全工具之前,你需要开发所有这些功能?绝对不是!要以最简的方式思考,并问自己:“所有与我类似的应用程序都具备哪些功能?”当你为用户开发产品时,可能会很容易想提前预测他们的所有需求,但这种心态既不现实也很昂贵:你往往会解决一些没有人遇到的问题(边缘情况代码),或者开发一些只会让新用户困惑的功能。如果你发布一个只包含少量明确标注功能的产品,你就能获得关于用户实际遇到的问题和他们缺失的便利功能的反馈。迭代改进计划能让你把开发时间集中在那些实际有用的功能上。

现在让我们深入探讨艺术画廊问题,它问的是:“在一个画廊中(由一个n顶点的简单多边形表示),需要放置最少的守卫才能确保所有内部点都能被看到?” 这是一个资源规划问题,类似于第九章中的消防站布置问题。一个好的安全人员、检查点和监控设备布置计划可以减少从一开始就需要响应的事件数量。它还可以在发生事件时改善响应时间,从而降低总体风险。不幸的是,安全团队中的人类规划人员通常对问题的理解程度不同,这可能导致计划不当(或实施不当)的安全控制。这就是为什么我总是在寻找自动化我们团队部分规划的方式。

正是在这些搜索过程中,我发现了艺术画廊问题,它解决了我正在研究的那个问题:如何高效地部署安全资源,尤其是对于那些我们称之为“非传统”布局的建筑。如你所见,并非所有的建筑设计都同样适合守卫,因此在深入问题细节之前,我们将先介绍我们计划开发的应用程序的使用案例。然后,我们将准备好开始开发应用程序的核心逻辑。我们会介绍现有的研究,并以最简单的形式展示理论。接下来,我们将定义用于解决问题的两种数据表示,并讨论数据结构。最后,我们将超越基础模型,加入诸如视野和预算限制等高级概念,从而实现更为现实的部署。

确定最小守卫人数

我们将使用原始问题陈述作为第一个使用案例:一个用户想知道保护一个非常规楼层平面图所需的最小守卫人数。我们希望所有的守卫一起能够观察整个画廊(在原始问题陈述中,所有的内部点或墙壁)。对于这个使用案例,我们的应用程序需要能够将楼层平面图编码成计算机可以分析的数据格式的功能,以及一个可以执行实际守卫布置的算法。我们将在本章的后续部分介绍这些内容,先介绍一些其他的使用案例。

下一个用例涉及到为安全设施的建筑设计提供参考,可以概括为:用户希望分析建筑物的安全覆盖范围和布局。在建造一个安全设施之前,一些潜在楼层平面的 CAD 草图将通过建筑信息建模(BIM)程序进行处理。假设的建筑设计将根据安全难度、紧急出口的通道、无障碍设施(如坡道和电梯)等进行评级。对于这个用例,应用程序需要定义不同类型的安全设备的有效覆盖范围,包括人类警卫、摄像头和其他电子传感器。

当然,这种分析也可以为攻击者提供安全布局的缺陷。每一部盗窃电影中都有一个场景,主角们会摆出目标的蓝图,开始标出无数的安全控制设备,直到他们发现一个漏洞。传感器盲区是这些电影中的常见桥段,但实际上,盲区确实是现实中的一个重要考量。通过观察使用中的警卫和传感器,可以制作出相当准确的覆盖图。寻找盲区通常只是一个简单的搜索模型技术规格文档的过程。像视场角度(通常以度为单位)和有效范围(以英尺或米为单位)这样的信息,告诉你设备不仅可以在哪里检测,还能告诉你它无法检测到哪里!为了支持这个用例,我们正在开发的应用程序将创建一个安全资源如何划分画廊的可视化布局。用户可以检查该布局,查看覆盖范围中的任何空隙。我们还将开发一个求解器,建议可以放置警卫的额外顶点,以实现可自定义的覆盖目标,同时引入距离、视场角和有效范围等概念,以便区分警卫、摄像头、运动传感器等设备。

许多建筑,特别是画廊和博物馆,都是多层建筑,因此我们可以假设使用我们软件的专业人士希望它能够适用于所有楼层平面图。由于项目需要包含多个资产,例如形状数据和楼层平面图本身,因此我们也可以合理推测用户希望有一种方法来备份他们的工作,或者在多个工作会话中恢复工作。我们将在最终的用例中将这些需求整合在一起:用户希望在多个工作会话中规划同一建筑物多个楼层的安全。我们开发的程序将通过创建一组可以序列化、编码并存储在压缩文件中的自定义对象来实现这一目标。我们将把多层建筑的每一层当作独立的 2D 楼层平面图,并将它们归为一个多楼层项目。每一层将包含一张图像,代表用来追踪画廊形状的背景,以及用户添加的所有几何信息。

学习建筑物的蓝图和 CAD 设计是一项非常好的习惯,尤其是对于物理渗透测试人员。仅仅通过了解一个设施的布局并自信地走进去,我就能把自己伪装成建筑物的一部分。毕竟,谁知道“设备间 #2”位于地下层西端,如果不是曾经来过那里的人呢?将对布局的了解与一个文件夹、工具箱或其他看起来像官方道具的物品配对,它通常和建筑通行证一样有效。

现在我们已经有了使用场景,可以开始深入探讨细节。我们先回顾一下原始问题以及过去的一些研究工作。接着,我们将深入讨论解决步骤,并讨论可以添加的额外约束条件,以便根据具体需求定制结果。

艺术画廊问题理论

第一个著名的艺术画廊问题定理是由计算机科学家兼教授 Václav Chvátal 于 1973 年提出的。这个问题是由华盛顿大学的数学教授 Victor Klee 提出的:

给定一个形状奇特的艺术画廊的平面图,假设它有 n 条直边,在最坏情况下,我们需要派遣多少名守卫才能确保每一段墙都能被守卫看到?

Chvátal AGP 定理 给出了一个上限,声明:“最多 n / 3 名守卫总是足够的,有时甚至是必须的,以覆盖一个具有 n 个顶点的多边形。”^(1) Chvátal 在他的证明中假设守卫将被放置在顶点上,但即使放宽守卫只能站在角落的限制,变成“守卫可以在多边形内部的任何地方”,Chvátal 的上限 依然成立。这个 3 常数来自于将画廊的形状分解为三角形,基于这样一种推理:你永远只需要每个三角形区域一个守卫。

Chvátal 的工作后来被数学教授 Steve Fisk 简化,他将这个问题简化为一个三色问题,定义如下:“在什么条件下,平面地图的区域可以被三种颜色着色,使得任何两个有共同边界的区域都不会有相同的颜色?”三色问题可以轻松地表示为图形,其中画廊形状的每个顶点构成一个节点,每条边表示两个顶点之间的共享墙段。然后,你可以将问题视为一个 顶点着色问题,即相同颜色的两个节点不能通过边直接连接。着色问题的图形版本是分析连通性的一种常用方法,因此 NetworkX 包含了一个已知为 贪婪着色 的函数来解决这个问题,我们将在解决方案中利用它。图 11-1 展示了贪婪着色算法的最简单情况。

图 11-1:解决最简单的艺术画廊问题

图 11-1 中的最左边图像显示了一个三角形多边形。n / 3 = 3 / 3 = 1 的上界意味着我们只需要一个守卫来观察整个内部。中间的图像显示了将三角形转换为图表示的结果。最后,最右边的图像展示了贪心着色算法的结果。正如预期的那样,每个节点都被涂上了不同的颜色,这意味着在这些点中的任何一个放置守卫都能使其观察到所有墙面。这是该过程的核心。为了扩展算法,我们只需要解决一系列相互连接的三角形,正如你在接下来的几节中会看到的几何和图表示一样。

在 2008 年的一篇论文中,Mikael Pålsson 和 Joachim Ståhl 研究了三色算法,并提出了一套替代的“矩形”算法(仅对直角多边形操作),这些算法特别为相机布置而设计。^(2) Pålsson 和 Ståhl 成功解决了 Chvátal 定理的几个实际问题。首先,通过限制自己只使用直角多边形,他们将上界降到了n / 4。其次,他们针对相机布置问题,解决了诸如视野有限、有效范围和障碍物等问题,这些问题同样适用于人类守卫和相机。这些附加目标使得布置选择比标准公式更加现实。稍后我们将进一步讨论视野和有效范围。我们还将增加对区域加权的能力,以优先考虑所需的覆盖范围。其他约束条件,比如确保每个相机都能被另一个相机看到(在高安全区域中很常见),不会在此讨论,但确实值得你去研究。

由于使用了直角多边形,Pålsson 和 Ståhl 的方法对于一般的安全布局规划不太实用。任何存在对角线或曲线墙壁的地方都需要多个小矩形来逼近形状。我最喜欢的非传统建筑之一是位于纽约的古根海姆博物馆,展示在图 11-2 中。

图 11-2:古根海姆博物馆,由 Frank Lloyd Wright 设计(© 2023 Frank Lloyd Wright Foundation。版权所有。由 Artists Rights Society 授权)。

左侧是下层的俯视图,讲座大厅。地面平面图上覆盖的网格代表 8 平方英尺。如你所见,在圆形墙壁附近和对角线墙壁附近,方格在某些地方被切断。这些地方是纯直角方法会遇到困难的区域。右侧则展示了博物馆的横截面,显示了贯穿大部分建筑的圆形结构。这为建筑带来了美丽的外观,但也使得用直角多边形绘制博物馆地图变得困难。

为了克服正交方法的缺点,我们将允许用户直接在建筑物平面图的图像上绘制一个或多个复杂的多边形。我们将把每个单独的多边形视为需要防护的独立区域,类似于原始问题定义。每个多边形将通过一种修改过的三角形镶嵌方法进行细分,称为约束德劳内三角剖分。然后,我们将把镶嵌后的几何形状转换成一个无权图,可以使用 NetworkX 中的贪心着色算法来求解。这个过程允许用户输入像古根海姆博物馆那样不规则的平面图,并在遵循现实资源限制的同时,求解出各种潜在的场景。

画廊的几何和图形表示

现在,让我们考虑如何将画廊空间表示为一个Polygon对象,就像我们在第七章中表示公园一样。图 11-3 展示了一个示例画廊。

图 11-3:用多边形和图形表示画廊

左侧是多边形,灰色区域表示画廊的内部。右侧是将形状转换为简单的 NetworkX 图的结果。你可以有多种方式将Polygon中的信息转换为图形,但最简单的选项通常是迭代Polygon外部的顶点,如清单 11-1 所示。

from shapely.geometry import Polygon, Point
import networkx as nx
import triangle as tr
❶ gallery_poly = Polygon([
    (0, 0), (0, 2), (0.55, 1.55), (1.4, 0.33),
    (1, 0.33), (0.45, 1.22), (0.25, 1.29)
    ])
gallery_coords = gallery_poly.exterior.coords[:-1]
G = nx.Graph()
❷ G.add_node( 0, coords=gallery_coords[0])
❸ pos = [list(gallery_coords[0])]
❹ for i in range(1, len(gallery_coords)):
    p = gallery_coords[i] 
    pos.append(p)
  ❺ G.add_edge(i-1, i)
  ❻ G.nodes[i]["coords"] = p
    if i == len(gallery_coords)-1:
      ❼ G.add_edge(i, 0)

清单 11-1:创建画廊表示

在清单 11-1 中,我们首先通过传入顶点列表来定义画廊的Polygon ❶,如之前所示。然后,我们添加第一个节点,表示坐标列表中索引为 0 的顶点 ❷。图中的每个节点都通过其在顶点列表中的索引来键入,以帮助保持这两种表示的逻辑关联。第一个点作为形状的锚点,使得创建边变得更加容易——我们稍后将完成这一步。然后,在此之前,我们必须将坐标添加到一个名为pos ❸的位置列表中,这样可以更方便地展示图形,使其与多边形的形状匹配。接下来,我们遍历剩余的顶点 ❹,完成图的构建。对于每个剩余的顶点,我们在之前定义的节点i-1和当前节点i之间添加一条边 ❺。添加边会创建节点V[(][i][)],并在一行中添加边E[(][i] [– 1,][i][)]。将顶点的坐标信息存储为节点的元数据,可以作为比pos列表更灵活的替代方式。我们将通过在coords参数中传递坐标来实现 ❻。最后,如果i等于最后一个索引,说明该是时候在最后一个定义的节点与锚节点(索引为 0)之间创建最后的闭合边了 ❼。

现在我们有了生成画廊两种表示方法的方法,让我们详细介绍如何求解艺术画廊问题的过程。

保护画廊

我们已经涵盖了艺术画廊问题(AGP)算法的开始部分,该部分创建了我们将要使用的两个基本数据结构。接下来的步骤是将几何形状切割成三角形,并将生成的边添加到图形表示中。记住,对其中一个表示所做的任何更改也需要在另一个表示中完成,以保持它们的逻辑等价性。与我们在第七章中使用的 Shapely triangulate函数不同,我们将使用一个专门构建的包装库,名为 Triangle。底层应用程序是一个基于 C 的程序,也叫做 Triangle,由乔纳森·谢吴奇克教授创建。^(3)选择 Triangle 而非 Shapely 有多个原因。首先,Shapely 版本执行的是所谓的德劳内(三角剖分),在其纯粹形式中并不尊重边界;而 Triangle 库执行的是约束德劳内三角剖分,能够尊重边界。列表 11-2 展示了切割画廊多边形的代码。

tri_dict = {
    "vertices": gallery_poly.exterior.coords[:-1],
    "segments": list(G.edges())
}
triangulated = triangulate(tri_dict, "pe")

列表 11-2:使用 Triangle 库执行三角剖分

triangulate函数期望接收一个包含两个必需键的字典。vertices键包含形状外部的坐标。segments键包含在执行切割时应强制执行的边。我们将字典作为第一个参数传递给triangulate函数。第二个可选参数是一个字符串,包含传递给底层应用程序的设置。配置标志有很多,你可以在字符串中传递多个参数。p标志告诉库将形状视为平面直线图e标志告诉库将边列表作为结果的一部分返回。包含边列表可以节省我们更新图形表示的一步,因为我们可以简单地将结果中的边与图形中的边进行比较,添加任何缺失的边。

triangulate函数返回一个字典。它包括一个名为vertices的列表,该列表根据每个顶点在此列表中的位置设置每个顶点的 ID。在解码其余输出时,我们将需要这个信息。我们目前感兴趣的另外两个键是triangles键,它包含一个表示构成每个三角形的三个点的三元组列表,以及edges键,它包含三角化结果中所有的边列表。trianglesedges都使用节点 ID 来表示顶点,因此三角形表中的一个条目如[6, 1, 0]意味着第六、第一和零号顶点组成一个三角形。如果你再次查看图 11-3 右侧的图形表示,你会看到从节点 6 到节点 1 的边(E[(6,1)])确实和节点 0 形成一个三角形。edges列表包含所有原始边以及构成三角形所需的所有附加边(就像前面提到的边E[(6,1)])。你可以使用edges列表或triangles列表来更新图形表示。Jupyter notebook 中有一个使用triangles列表的示例,但我在列表 11-3 中的代码选择了edges列表,因为它更简洁。

G2 = G.copy()
for e in triangulated["edges"]:
    if list(e) not in list(G2.edges()):
        G2.add_edge(e[0], e[1])

列表 11-3:更新画廊表示

首先,我们创建一个原始图形G的副本进行处理。我们遍历三角化结果中的edges列表。对于每一条边,我们检查它是否已经存在于G[2]的边列表中。如果没有,我们就将其添加进去。最终结果是一个同时具有几何表示和图表示的画廊三角化表示,如图 11-4 所示。

图 11-4:示例画廊的三角化结果

你可以看到,左侧图表中添加到图形中的分割线段与右侧图表中添加的新增边相匹配。由镶嵌产生的顶点和线段集合在技术上被称为网格

我们现在可以使用greedy_color函数来为图形着色,你需要从networkx.algorithms.coloring库中导入该函数,方法如下:

from networkx.algorithms.coloring import greedy_color
gallery_coloring = greedy_color(G2)

函数的返回值是一个以节点标识符为键的字典。值表示该节点所属的颜色组的数字索引。图 11-5 显示了示例画廊的解决方案。

图 11-5:贪婪着色示例画廊的结果

左侧显示的是由着色算法找到的解决方案。在图的右侧,我已经移除了三角化边,因为一旦着色完成,我们不再需要它们。我还使用不同的形状—方形、菱形和圆形—来表示颜色组,以便更容易区分它们。你可以把每个组看作是一个潜在的守卫部署计划。如你所见,并不是所有的部署都有相同数量的守卫位置。圆形组有三个节点,而其他组只有两个;这意味着如果将守卫安排在圆形节点标记的位置,你需要部署一个额外的守卫来覆盖所有墙面。

你可以比较节点数最少的几种部署,看看每种部署方案提供的实际优缺点。例如,方形和菱形的部署组都只需要两个守卫,但菱形部署将守卫安排得比方形部署更靠近。虽然这看起来是一个微小的差异,但在现实中,两个守卫更接近可以更高效地相互支援;因此,假设这些部署是我的唯一选择,我会选择菱形部署。

现在你已经看到应用于示例画廊的基本解决方案概念,是时候开始优化这一过程,并使解决方案更具实用性,以便在现实世界中使用。本章的其余部分将重点关注完善你刚刚看到的理论方法,以解决之前提到的一些实际问题。

覆盖范围映射

到目前为止,我们忽略了画廊的规模。原始问题假设守卫拥有完美的、无限的视力,不受光线、距离或人群的影响。但在现实场景中,部署需要考虑这些因素,并为每个守卫位置设定某种最大覆盖阈值。如果我们说示例画廊的比例是 1:300 米(意味着图中的一个单位等于 300 米),则边E[(0,1)]的长度是 600 米,E[(0,1)] = 2 × 300 米 = 600 米(约合 1,968.5 英尺)。因此,画廊的面积大约是 241 平方米(2,594 平方英尺)。

仅用两个守卫来覆盖如此大的平面图,可能会导致覆盖出现空隙。再加上其他影响能见度的环境因素,^(4),比如地面坡度引起的高差和照明(画廊的某些部分通常为了戏剧效果而调暗),显然两个守卫不足以保护整个画廊。因此,我们需要改进理论模型中的简化假设。我们将通过承认守卫一次只能保护有限的区域来做到这一点。这是我选择 Triangle 库的第二个主要原因:它支持最大区域阈值的概念,而 Shapely 版本的函数不支持。最大区域阈值设置了镶嵌过程中创建的任何三角形的最大面积。

如果将每个三角形视为需要守卫的区域,你可以将它们分配到守卫位置,以创建一个责任区(AOR)地图,显示哪些位置负责哪些区域,以及整体的覆盖分布(我们在第九章的消防站示例中看到过 AOR 地图)。每个区域三角形的面积越小,三角形之间的间距就越小,因此需要更多的三角形来镶嵌整个画廊。更多的三角形意味着镶嵌中会有更多的点,从而导致更多的守卫位置。这也意味着需要更多的颜色(或形状)组来表示它们。

为了简化数学计算,假设每个区域三角形的最大面积应为 30 平方米(接近 323 平方英尺),这相当于每个三角形面积为 0.1 单位。我们可以告诉 Triangle 将画廊镶嵌成最大面积为 30 平方米的三角形,方法是将 a 标志添加到参数字符串中,后跟缩放后的最大面积 0.1:

triangulated = tr.triangulate(tri_dict, "pe**a0.1**")

要进行镶嵌,triangulate 函数会添加必要的点(通常称为Steiner 点),使得几何图形将大于最大面积的三角形划分为更小的三角形,直到它们都低于阈值。图 11-6 显示了镶嵌的结果。

图 11-6:最大区域面积 30 平方米的解决方案

左侧是网格的图表示,右侧则展示了不同的潜在守卫部署。由于每个节点属于多个三角形,因此该位置的守卫将负责该节点所属的所有三角形区域。此外,如果有任何三角形没有直接连接到某个守卫位置(例如,右下角画廊中的三角形[3, 4, 10]),我们会将该三角形分配给距离最近的守卫位置,从而生成 AOR 图。解决图着色问题需要算法添加第四组,由星形节点表示。记住,你可以将额外的组视为更多的潜在部署选项,而部署中的额外点则表示更多的守卫被添加到该部署中。在这个例子中,圆形部署和星形部署都需要覆盖三个位置,而方形和菱形部署则需要四个位置。按照之前的逻辑,我会选择圆形部署,因为它将守卫位置安排得更紧凑,而星形部署则留下了一个相对孤立的位置。图 11-7 显示了从圆形部署生成的 AOR 图。

图 11-7:从选定的部署创建 AOR 图

图的左侧显示了三角形分配给守卫的默认方式。黑色三角形是前面提到的未覆盖三角形。在图的右侧,你可以看到将三角形分配给最近的守卫位置进行覆盖的结果。这确实在部署中产生了轻微的不平衡。浅灰色的 AOR 包含了五个三角形(因此覆盖面积更大),而其他两个 AOR 只有四个三角形。

列表 11-4 显示了根据部署组将三角形分配给守卫的函数:

def assign_triangles(g, triangulated, group_id):
  ❶ guard_nodes = [n for n in g.nodes() if g.nodes[n]["group"] == group_id]
  ❷ triangles = {k:[] for k in guard_nodes}
    for i in range(len(triangulated["triangles"])):
      ❸ t = triangulated["triangles"][i]
      ❹ t_poly = Polygon([g.nodes[p]["coords"] for p in t])

        # If triangle touches a guard directly at any point:
      ❺ if t[0] in guard_nodes:
            triangles[t[0]].append(t_poly)
        elif t[1] in guard_nodes:
            triangles[t[1]].append(t_poly)
        elif t[2] in guard_nodes:
            triangles[t[2]].append(t_poly)
        else:
          ❻ dists = {
                k: t_poly.distance(
                     Point(g.nodes[k]["coords"])
                   ) for k in guard_nodes}
          ❼ close = min(dists, key=dists.get)
          ❽ triangles[close].append(t_poly)
  ❾ return triangles

列表 11-4:将三角形分配给守卫节点

assign_triangles 函数接受三角化图 g 的副本、triangulate 函数的结果 triangulated,以及感兴趣部署的 ID group_id。我们首先基于提供的组 ID ❶,将代表守卫位置的节点收集到名为 guard_nodes 的列表中。接着,我们创建一个字典来保存函数的输出,直到 return 语句。字典的键是节点标识符,值是一个空列表,最终将包含分配给该节点的所有三角形(作为 Polygon 对象)❷。

要开始填充triangles列表,我们遍历triangulated中的所有三角形。每个三角形代表了构成三角形❸的图形节点集合。我们通过查找图形g中该三角形的坐标,将节点转换为Polygon❹。下一步是检查三角形节点是否已经包含了某个守卫位置。如果包含,则三个点中的一个将出现在guard_nodes列表中❺。如果三角形与守卫没有直接连接,我们将确定它与哪个守卫位置更接近。

接下来,我们创建一个名为dists的字典,字典的键是守卫节点的 ID。每个键的值将是 Shapely 函数t_poly.distance的结果,该函数测量三角形多边形与表示守卫位置的Point对象之间的最小距离❻。然后,我们使用min函数查找dists字典中的最小条目。通过在key参数中传递字典的键列表,函数会返回具有最低值的键,而不是值本身❼。我们使用该键将多边形分配到triangles中的正确列表❽。最后,我们返回triangles字典以结束该函数❾。

我们可以通过求和与每个 AOR 相关的所有三角形的面积来确定每个 AOR 的精确面积,如示例 11-5 所示。

for k in triangles.keys():
    area = sum([(t.area * 300) for t in triangles[k]])
    print("Position %d covers %.2fm2" % (k, area))

示例 11-5:求和 AOR 面积

对于结果中triangles字典中的每个键,我们对与之关联的三角形面积列表执行sum操作。注意我们通过将每个面积乘以缩放因子(此例中为300)来调整比例。示例代码的结果应该是:

Position 2 covers 60.41m2
Position 11 covers 98.68m2
Position 12 covers 82.34m2

关于三角形分配函数,有一点重要的说明:它实际上只是一个启发式方法。根据画廊的形状以及不同边缘和顶点的位置,有可能将一个三角形分配给“更接近”的守卫(通过最小直线距离来衡量,或者说是直线距离),但从逻辑上讲,它应该分配给另一个守卫。你可以通过考虑 Shapely 测量的直线距离是否与画廊的主体相交来改进该函数;如果相交,这表明测量路径穿过了墙壁,因此比实际情况要短(除非你的守卫能够穿透像混凝土墙壁这样的实心物体)。你可以改变函数,使其将三角形分配给不与任何墙壁相交的、具有最短距离的守卫位置。

另一个选项是更改部署,添加一个额外的防护位置来覆盖未覆盖的三角形。仔细观察图 11-6 右侧的图形表示,你会发现节点位置 3 的星形也可能是一个圆形。有时候会有多个可能的解决方案,着色算法必须选择它认为最优的一个(这意味着它尽可能均匀地分配颜色组)。与其直接将未覆盖的三角形分配给防护位置,你可能会选择将星形节点更改为另一个圆形节点;这样可以保持解决方案的有效性,同时确保每个三角形都被部署覆盖。另一方面,这也意味着需要向部署中添加一个额外的防护,这在某种意义上降低了其最优性。你应用的未来改进可能会允许用户在创建 AOR 地图时根据其特定需求选择分配策略。

定义受阻区域

现在我们已经涵盖了算法、它如何处理缩放以及基本的 AOR 覆盖地图,是时候处理复杂多边形了。如你在第七章中回顾的那样,这些多边形有孔洞,代表着不能访问的区域或遮挡视野的区域(例如,一块巨大的花岗岩柱子)。

幸运的是,在 Triangle 库中处理孔洞相对简单。对于我们要定义的每个孔洞,我们向 triangulate 函数传递一个位于孔洞内的点(任何点都可以)。算法会不断移除三角形,直到遇到字典中 segments 部分定义的一个顶点。需要注意的是,如果你不小心将孔洞包围在段内,可能会意外地移除所有三角形。如果发生这种情况,你将不会在 triangles 键中收到任何结果,因此你可能需要更新实现,使用某种有效性检查来处理这种情况。

要获取孔洞的点,可以使用 Shapely 的 representative_point 函数,该函数返回一个 Point 对象,保证位于调用该函数的形状的边界内。使用代表性点的好处在于,Shapely 不在乎点是否位于形状的中心,因此能够快速计算。只要该点位于给定形状内,就被认为是代表性点,这对于三角形来说非常有效,因为它只需要知道从哪里开始移除三角形。

列表 11-6 展示了创建复杂多边形镶嵌的代码。

ext_3 = [(0.0, 0.0), (0.0, 3.0), (3.0, 3.0), (3.0, 0.0)]
int_3 = [(1.0, 1.0), (1.0, 2.0), (2.0, 2.0), (2.0, 1.0)]
verts = ext_3 + int_3
❶ hole_p = Polygon(int_3).representative_point()
❷ hp1 = [list(v)[0] for v in list(hole_p.xy)]
segs = [(0,1), (1,2), (2,3), (3,0), (4,5), (5,6), (6,7), (7,4)]
sq_tri_dict = {
    "vertices": verts,
    "segments": segs,
  ❸ "holes":[hp1]
}
triangulated = tr.triangulate(sq_tri_dict, "pe")

列表 11-6:镶嵌复杂多边形

我们首先通过定义画廊多边形的外部坐标,将其存储在名为ext_3的列表中。在此情况下,点的排列形成了一个大致的正方形画廊。接下来,我们定义一个名为int_3的列表,用于存储洞的顶点坐标。这些点形成一个较小的正方形洞,直接放置在较大正方形外部的中心位置。然后,我们创建一个名为verts的顶点列表,它是所有外部和内部坐标的连接列表。我们可以通过将int_3列表作为其自身的多边形,并调用前面提到的representative_point函数来计算洞的代表点。

hole_p变量现在保存着一个Point,其xy值可用于向 Triangle 库标识洞区域 ❶。由于 Triangle 无法直接处理Point对象,我们必须将坐标信息提取到一个列表中 ❷。接下来,我们创建了segs列表,其中包含必须被镶嵌算法遵守的边列表。与使用坐标不同,边缘使用两个顶点的索引作为每条边的起始和结束点。在更复杂的平面图中,直接从图的表示中获取边缘列表可能更方便。只需确保所有边缘和节点都已正确输入,否则您将得到一些意外的结果。

最后,我们可以构建用于调用triangulate函数的参数字典。与之前的参数字典唯一的区别是增加了holes键 ❸,它包含一个坐标列表,表示需要移除的洞内的某个点。当处理多个洞时,您需要为每个洞计算一个代表点,并将其添加到字典中的洞列表中。

图 11-8 展示了应用于示例复杂多边形画廊的步骤。

图 11-8:将 AGP 算法应用于复杂多边形

从左到右,您可以看到应用于画廊的整个解决过程。最左侧图中的白色正方形代表画廊中央的洞;较暗的灰色区域表示可用的平面图。在第二幅图中,您可以看到将几何形状转换为图的结果。请注意,洞部分被完全边界围绕。这些边界作为segments键(与之前的外部边界一起)传入 Triangle 的triangulate函数。第三张图片展示了根据洞进行镶嵌的结果。triangulate函数首先在不考虑洞的情况下创建镶嵌,然后开始移除三角形,从包含洞内点的三角形开始;它会继续移除相邻的三角形,直到遇到segments列表中的边缘。由于没有应用面积约束,因此结果是一个包含最少三角形的三角化。

第四个图显示了更新后的图形,准备应用颜色求解。如你所见,第二和第三个图中的任何边都没有跨入孔区空间;这表明 Triangle 在生成网格时尊重了我们忽略该部分空间的请求。右侧的最终图显示了基于着色解决方案分配部署组的结果。有四个潜在的部署,每个部署由两个保安岗位组成。如果没有孔区,你理论上只需要一个保安就能覆盖整个画廊。

优先考虑安保覆盖区域

接下来,我们将完善我们的模型,解决另一个我们之前隐含的假设:即认为画廊的所有区域同等重要,因此安保人员的部署应该是均匀分布的。例如,一家银行可能会认为私人办公室的监控不如大厅重要,因此需要不同的资源来保护不同的区域。为了解决这个问题,我们将优化最初的网格,以体现楼层平面图中各区域的重要性(或权重)。底层的 Triangle 程序支持多个最大区域(通过自定义数据文件读取),这使我们能够在定义不同的 AOR(活动区域)时更加灵活。为了将这个例子与我们的艺术画廊安保相结合,也许你认为画廊的楼层比讲座厅需要的保安更少,因为人们倾向于在讲座厅形成更大的群体。你可以在图 11-9 中看到将区域分配应用于方形画廊的结果。

图 11-9:添加区域分配后的画廊

在图的左侧,你可以看到我为方形画廊示例定义的四个编号区域。每个区域的中心用星号标出,并显示该区域的编号。这些区域是整体画廊多边形的分段边界部分,因此可以使用任何在该段边界内的点来定义(类似于定义孔的方式)。在这种情况下,我在已经定义的分段边界列表中添加了四个理论分段(对角线分隔线)以包围这些区域。它们是理论性的,因为与表示墙壁和其他物理结构的其他分段不同,区域分段表示的是区域之间的逻辑边界,而不是物理边界。请记住,Triangle 并不了解这些差异,因此由我们来跟踪哪些分段属于哪个类别。我们可以利用图形表示中存储的边缘属性来做到这一点。

为了让 Triangle 知道我们有感兴趣的区域,我们还需要更新传递给包装器的输入字典,加入regions键,如清单 11-7 所示。

sq_tri_dict = {
    `--snip--`
    "regions":[
        [1.5, 2.5, **0.** , 0\. ],
        [0.5, 1.5, 1\. , 0\. ],
        [1.5, 0.5, 2\. , 0\. ],
        [2.5, 1.5, 3\. , 0\. ]]
}

清单 11-7:为镶嵌添加区域定义

regions键的值是一个嵌套列表。每个条目包含表示相关区域内某个点的 x 和 y 坐标。在初步网格划分过程中,围绕每个区域代表点找到的边界段中的三角形将被分配第三位置的值。列表中的第四个位置以及后续的每个位置可以包含一个数字区域属性(例如,修改后的可见性值,以标记具有更柔和光照的区域)。你所包含的任何区域属性的值将被区域内的三角形继承。

一旦定义了区域,我们就可以进行初步的网格划分。我们将通过在triangulate函数调用中添加A标志来告诉 Triangle 将区域信息包含在结果中:

triangulated = tr.triangulate(sq_tri_dict, "pe**A**")

结果的网格显示在图 11-9 的右侧。每个三角形内的数字表示已分配给该三角形的区域标识符。到目前为止,过程与之前相同,因此网格保持不变。

下一步是将结果保存在一组特殊的文件中;这些文件本质上是字典中的相同数据写入到平面文本文件中。我们需要这样做,以便在精化阶段重新加载文件。不幸的是,包装库不包括任何保存功能,因此我必须根据底层程序文档中的文件规范编写自己的函数。项目代码包含了创建预期文件所需的所有功能,但它们很长而且相当枯燥,所以我在这里跳过详细介绍。你只需导入我在本章代码中提供的DataSaver类,如清单 11-8 所示。

from filemanager import DataSaver
saver = DataSaver(triangulated, "square", 1, "/myproject/")
saver.set_region_areas([-1,0.1,-1,0.05])
saver.save_project()

清单 11-8:将网格划分结果保存到 Triangle 项目文件

我们首先定义DataSaver实例,将triangulated字典作为第一个参数,项目名称作为第二个参数传入。你还可以选择性地传入版本号和目录。如果不传入,版本号将自动设置为 1,目录则为脚本的当前工作目录。set_region_areas函数存储最大区域列表,列表中的索引与区域索引相同。如果你传入的列表比区域数量短,那么剩余的区域将被视为没有面积约束。

在使用save_project保存项目之前,我们需要设置区域面积,以便保存器知道如何在文件创建过程中标记每个三角形。一旦save_file函数完成,你将会在你指定的目录(或者如我之前提到的当前工作目录)中看到一系列文件。每个文件命名方式如下:<项目名称>_<版本号>.<部分>,其中<部分>是 Triangle 预期的文件类型之一(带有.node.ele.area.poly扩展名)。因此,清单 11-8 中的代码将创建一个名为/myproject/square_1.area的文件(还有其他文件)。.area文件尤为重要,因为它包含每个三角形的最大面积,这是我们通过区域关联设置的。

接下来,我们需要通过重新加载保存的数据并执行另一次网格剖分来细化网格:

reload = tr.load("/myproject/", "square_1")
refined = tr.triangulate(reload, "ra")

我们从包装库中调用load函数,并传入存储项目文件的目录以及项目名称(包括版本)。这些输入用于查找并加载在清单 11-5 中创建的相关文件中的数据。最后,我们通过再次调用triangulate函数来创建细化后的网格,这次是在加载的数据上进行操作。选项字符串ra告诉 Triangle 细化先前生成的网格(r标志)并使用约束的三角形区域来细化网格(a标志)。由于 Triangle 正在细化网格,并且我们要求它使用约束区域,它将尝试定位项目的.area文件,并在创建细化网格时使用该信息。

在细化步骤中,原始网格剖分中的每个三角形都会与.area文件中定义的区域进行比较。如果区域大于定义的最大面积,算法会将该三角形分割成更小的三角形。你可以在图 11-10 中看到区域面积应用到正方形画廊网格剖分的结果。

图 11-10:创建多区域网格剖分

在左侧,你可以看到区域分配。传递给DataSaver类的区域面积为[-1,0.1,-1,0.3],可以解读为:区域 0 没有最大面积约束(由任何负值标记);区域 1 的最大面积约束为 0.1;区域 2 同样没有应用约束;最后,区域 3 的最大面积约束为 0.3。右侧显示,区域 1 被分割成了许多小三角形,而区域 3 则被分割成了稍大的三角形。结果是,区域 1 中的防护位置比区域 3 多,而这两个区域与没有约束的其他两个区域相比,覆盖面积更大。

我们可以使用另一个DataSaver类实例来保存细化后的网格:

saver = DataSaver(refined, "square", 2)
wsaver.save_project()

我们再次传入refined网格结果、项目名称和版本。每次细化网格时都按标准做法增加版本号,因此这个细化后的网格将是方形项目的第二个版本。这次我们不需要定义区域区域,除非我们想要进行进一步的细化。

当然,如果你可以向部署中添加任意数量的守卫,那就太好了。不幸的是,我们很少有预算为每班次配备更多的守卫,或者为每层楼部署固定数量的传感器设备。我们可以通过S标志告诉 Triangle 它可以添加的最大 Steiner 点数,以实现切割。比如说,假设我们只能负担得起在方形画廊部署三个额外的守卫。图 11-11 显示了在细化步骤中加入这个约束后的结果。

图 11-11:带有三个守卫的细化网格

在左侧,你可以看到我们之前生成的网格,那里没有添加守卫点的限制。在右侧,你可以看到从细化中生成的网格,我将输出限制为三个额外的守卫位置。请记住,我没有更改之前定义的最大区域约束,显然右侧的网格并没有满足这个约束。一旦 Triangle 无法再添加 Steiner 点,它就无法进一步划分较大的三角形,因此它会停止。如果要求你将画廊划分为小区域,然后用仅三个额外的守卫来覆盖这些区域,你可以用这个结果证明同时实现这两个目标在数学上是不可能的。希望这个结果能为你争取到增加这些额外守卫的预算!

安全摄像头视野映射

接下来需要处理的特性是添加视场和有效范围参数。在讨论人类守卫时,视场和有效范围因人而异,因此分配这些参数有些模糊。在这种情况下,直觉和有根据的估算是最好的朋友。然而,当你把守卫位置看作电子设备时,比如摄像头或运动探测器,数据就容易找到。例如,我在 Google 上搜索了“监控摄像头数据表”,并选择了我看到的第一个型号:NetGear 的 Ocuity 型号 HMNC100。通过查看该型号的技术数据(可以在制造商网站上找到),我发现列出的视场角度为 107 度,完全黑暗中的有效范围为 7 米(得益于内置红外照明)。在正常工作照明条件下的距离通常没有列出,因为答案取决于你需要区分的细节程度、摄像头的焦距(可能在用户文档中没有列出),以及用于编码信息的像素数量(通常以百万像素(MP)为单位,1MP 等于一百万个像素)。为了获得有效距离的较好数值,最好的方法通常是直接在感兴趣的条件下测试摄像头。

我们假设正常有效范围 r(d) 是夜间范围 r(n) 的 20 倍,r(n) = 7 米(r(n) × 20 = 140 米,即约 460 英尺)。由于我们在做近似计算,我还将视场 Δ 从 107 改为 104,因为 107 是质数,无法均分成相等的段;这会使得计算视场边缘的点变得更困难。另一方面,104 可以被多个因子整除,包括 2,这也有助于简化计算。我将定义四个内部守卫节点 [4, 5, 6, 7] 的起始角度 ϴ[(0)] 分别为 [180, 134, 45, 351]。我们可以将正向外围角定义为 m11001,负向外围角定义为 m11002

最后,我们可以通过将视场分成 8 个 13 度的段落,来创建两外围角之间的一些中介角度:

δ = Δ / 8 = 13

然后,我们可以对每个角度使用 cossin 函数:

A : = [ θ( p ) − i δ] ∀ i ∈ ℤ0 − 8

图 11-12 显示了如果你将其中一台摄像头放置在 图 11-8 中方形示例的四个内部守卫位置之一,并使用刚才提到的起始角度时,视场的近似覆盖情况。

图 11-12:定义视场多边形

图 11-12 展示了将其中一台相机放置在内部孔的每个角落时,视场覆盖的近似情况。如您所见,明显有大面积区域是相机无法充分覆盖的。此外,您还可以看到,理论上,有人可以在不进入任何相机视野的情况下,接近每个相机位置。这些就是本章早些时候提到的盲区示例。作为防守方,您需要增加更多的覆盖范围来防止这些空隙。作为攻击方,您可以将所有未覆盖的资产视为潜在的好目标。

关于视场映射的最后一点说明。我假设这些相机是指向并保持静止的。几款高端相机包括一种叫做云台的装置,可以让远程操作员根据需要移动相机。部分中端相机也包括自动前后扫视一个区域的功能。这些类型的相机近年来变得不太流行,但仍然值得了解;并非所有的视场地图都是静态的。在分析设备数据表时,务必留意这些特性;您可能需要更新实现,以适应这些情况下的完整视野范围。

总结

现在,您已经掌握了解决艺术画廊问题核心所需的所有代码,包括处理现实世界约束条件的方法,如有限预算和传感器有效范围。我们已经讨论了 Triangle 库的强大功能,并将其与 Shapely 和 NetworkX 结合,采用几何和图形表示法对问题进行了建模。也许最重要的是,您现在应该能够自如地解释 AGP 理论,并讨论应用中所涉及的实际约束条件。您可以利用大量的研究资料继续深入理解这个问题。例如,我甚至没有涉及贪心着色算法如何在后台运行。^(5) 一般来说,节点着色是一个伟大的图论话题,除了 AGP 之外,它还有很多应用;但就我们来说,研究节点着色算法可以帮助您更全面地理解您的应用输出的解决方案(以及您可能如何修改它们)。关于不同画廊布局的几何含义,您可以查看论文《艺术画廊问题注解》以获得更深入的描述。^(6)

为了继续开发系统的核心,你可以识别可能对目标用户有吸引力的额外用例。例如,你可能希望有一个用例来覆盖那些想要比较部署前后计划的用户。一旦收集了这些用例,你可以开发支持它们所需的额外功能。此外,你还可以继续完善此处包含的功能。三角形分配功能是一个优秀的改进领域,你可以使用我们之前讨论过的某个选项来提升它。记住,你不一定需要在将应用程序推向市场之前开发所有这些功能。开发最小可行产品(MVP)的关键在于选择那些能让你的应用对用户有用的核心功能。通常,确定这些核心功能就是分析竞争软件并回答两个问题:“这些应用都提供了哪些功能?”以及“我的应用提供了哪些其他应用没有的功能?”

一旦你能回答这两个问题,你就可以继续进入下一章,我们将从本章开发的核心算法,扩展为一个完整的 Python 应用程序。我们将通过绘制用户交互图、添加图形、选择现代处理架构以及决定是否添加应用程序许可,来完成这个项目。

第十四章:最小可行产品方法在安全软件开发中的应用

超越当前的概念验证代码意味着需要规划其他用户如何与程序互动。通过从开始到结束绘制用户在应用程序中的路径,你可以决定最适合的交付方式,这将使得用户能够快速直观地开始使用你的软件。本章中的考虑适用于大多数类型的应用程序,因为它们涉及的是交付和使用应用程序的过程,而不是直接解决问题。

在上一章中,我们确定了一些用例,并据此定义了应用程序需要支持的几个功能。在本章中,我们将实现仍然开放的功能:开发图形用户界面(GUI)和保存项目。我们将首先映射用户与程序的交互,利用这些交互来构建 GUI。接下来,我们将讨论状态管理器以及如何利用进程并行性来分配工作负载,优化我们对复杂平面图的解决方案。最后,我们将构建 GUI 并实现保存功能。

映射用户的交互

大多数优秀的软件项目在开发阶段都会有一套用户交互计划,描述用户在应用程序中实现每个用例时的实际步骤。这些计划可以是粗略的草图,也可以是非常详细的最终应用程序线框图,但它们都需要回答一个问题:“用户如何使用系统来实现目标?”我偏好的用例绘制方法是使用应用程序状态机作为结构,并使用统一建模语言(UML)来表示过程。我建议使用像 Dia 或 LucidChart 这样的应用程序来制作可视化布局。图 12-1 展示了我在 LucidChart 中为一个用户希望跨多个会话保存其项目的用例所开发的工作流程图。

图 12-1:多会话工作流程图

我选择这个特定的用例,因为它涵盖了整个应用程序流程:我们创建多个楼层,绘制多边形表示,保存和重新加载项目数据,以及创建最终的解决方案可视化。在图 12-1 的左上角,你可以看到一个椭圆形状,它表示用例的入口点。在这个场景中,想要跨多个会话工作的用户首先初始化他们的项目。现在这仅仅意味着他们第一次打开程序,但我们将在后续章节中对初始化过程进行更多的扩展。

圆角矩形表示应用程序的高级状态。这些状态类似于我们在第六章中讨论有限状态机时所考察的状态:它们表示每个状态下可用的选项,以便转换到另一个状态。当用户初始化项目时,他们会进入Start状态,在此状态下,他们需要创建一个或多个包含背景 PNG 文件的Floor对象。一旦表示楼层平面图的Floor类被创建,它就会传递到用户进入的下一个状态,即Started状态。

Started状态下,用户可以选择绘制楼层平面图的形状数据。为此,他们点击鼠标创建一系列表示平面图外壳的点,并在外壳内添加点来定义孔洞(这些孔洞由应用程序原语中的Obstacle类表示)。一旦用户添加了一个点以开始绘制形状,他们必须完成绘制或使用undo功能撤销操作。用户绘制完他们需要的形状后,Shapely 数据会被发送到状态管理器,这是整个应用程序的核心。稍后我们会更详细地讨论状态管理器,但现在只需要理解状态管理器的工作是跟踪用户在应用程序中的操作,并暴露适当的操作和施加适当的约束(例如,在保存之前必须完成绘制形状)。

一旦用户绘制完他们希望在会话中完成的内容,他们将进入Drawn状态,在这个状态下,他们会保存信息以供下次会话使用。为了告诉状态管理器记录各种文件和对象,用户按下键盘组合键 ctrl-S。在这个状态下,用户可以选择返回继续绘制——这允许在单个会话中进行增量保存——或结束程序并稍后再回来。当用户返回继续后续会话时,他们会进入Saved状态,在该状态下,他们可以使用键盘组合键 ctrl-O 要求会话管理器加载之前保存的项目。加载完成后,用户可以添加新的楼层(Start状态),或者继续在先前创建的楼层上绘制(Started状态)。一旦所有必要的形状都被记录下来,用户可以通过键盘组合键 ctrl-P 解决楼层问题。结果将是一个图像文件,其中包含最低计数的部署并覆盖在楼层平面图的背景图像上。

总体来说,这个实现的功能集仍然相对基础;这是故意设计的,目的是让你能够扩展程序以实现你对项目的设想。应用程序开发中的一部分艺术就在于你如何选择组织功能模块,因此我不会再详细说明我认为哪些部分应该放在哪。只要你以符合自己理解的方式安排功能,满足用例需求,它们就会成为指导项目开发的地图。

计划应用程序状态

一旦你创建了其余的应用状态流程图,你就可以开始拆解支持每个应用状态所需的代码。这个过程是关于识别对最多功能产生影响的应用部分,并开发必要的代码来支持这些功能的。遵循这个应用映射过程有助于通过识别应用程序中共享的部分来减少不必要的代码,这样你就可以使用可重用的类或函数来开发它们。因为它允许你追踪哪些交互触及了哪些代码部分,这个过程还帮助你评估每个部分对应用程序性能的相对重要性。例如,通过查看图 12-1 中的图示,我们可以知道我们需要一个状态管理器类来跟踪应用程序中的操作,一个楼层类来表示楼层平面图,以及某种支持键盘输入的方式(比如热键命令 ctrl-S 和 ctrl-O)。我们知道状态管理器需要能够接收形状数据,并输出存储重要细节的文件。它还需要能够读取这些文件,并从中重建最后保存的状态。

创建应用程序地图也有助于你组织支持这些需求所需的库和模块。我们已经知道需要从上一章中引入 Triangle 和 NetworkX 库。由于我们需要图形显示、键盘快捷键和鼠标交互,PyGame 库是开发用户界面的一个不错选择——它能够同时支持这三项需求。由于我们使用的是背景 PNG 文件,我们需要一个能够处理这种文件类型的工具。我们可以使用一个名为 png 的特定 PNG 库,它与 PyGame 的功能配合得很好,或者使用 imutils 或其他类似的库。让你的应用程序图示引导你选择库,并阅读不同选项的文档。寻找像 PyGame 这样的库,它能够解决多个需求。当两个选项看起来同样适合一个任务时,我通常选择能减少整体需求大小的那个。这意味着选择已经包含在需求库中的库,或者选择文件大小较小的那个包。

在实际开发中,你包含的用例数量以及你处理它们的顺序将很大程度上受到外部因素(如业务需求和预算)的制约,因此没有人能给你一个通用的开发流程来遵循。尽管如此,从高层次来看,开发用例图和应用程序流程图几乎总是能帮助你保持目标明确。

接下来,我们来讨论一个在概念验证开发中常常被忽视的话题:为了人类的福祉,记录我们的项目。

记录应用程序

对于任何重要项目来说,良好的文档都是绝对必不可少的。我们已经通过应用程序状态图开始了文档工作。当该软件交付时,我们可以将这个图表与其他项目文档一起提供给用户,展示我们应用程序的基本功能。除了项目文档外,我们还应该对源代码进行文档记录。这个项目的代码采用了一种叫做docstrings的方法,旨在为我们自己和未来的开发者记录代码。Docstrings 是直接添加到脚本文件中的注释,使用易于人类阅读的语法。这个话题在网上有大量详细的讨论(peps.python.org/pep-0257),所以我在这里不会展开讨论,但我认为展示一个例子会更有帮助:

def concat_str(a, b):
    **'''**
 **Returns the concatenation of string 'b' to string 'a'.**
 **Parameters:**
 **a (str): A string literal**
 **b (str): Another string literal**
 **Returns:**
 **concat_str (str): String after adding 'b' to the end of 'a'**
 **'''**
    concat_str = a + b
    return concat_str
print(concat_str.__doc__)

函数的 docstring 是紧接在函数定义后面,使用三引号(''')括起来的字符串字面量。注释涵盖了函数的输入和输出(在示例中的ParametersReturns部分),记录了类对象的预期用途,并为可能想要进行未来修改的人记录任何重要的说明或事实——例如,包含一个指向函数所使用的特定算法源材料的链接。我们可以通过内置的.__doc__属性访问任何函数的文档。Docstring 语法还允许自动化程序检测这些注释,并将它们格式化为更美观的 API 文档供大众使用。

拥有良好的文档能够帮助你迅速引入新的开发人员。如果你是为一个企业开发这个应用程序,你可以轻松地培训团队中的其他开发人员,并使他们能够开发扩展功能和改进。同样地,如果你是为开源社区开发,良好的文档会鼓励贡献优秀的代码。即使你只是为自己开发,强大的文档实践也能帮助你在长时间不接触项目后,迅速找回记忆。

现在我们已经讨论了保持代码高效并促进其他开发者采用的所有规划要点,我们可以开始项目的有趣部分:开发应用程序的核心——状态管理器。

开发状态管理器

在现代软件中,状态管理器 极为常见,管理程序中所有可能的交互。例如,你的网页浏览器需要跟踪你点击的地方、你输入的内容,以及浏览器中发生的所有其他事情。浏览器的状态将决定这些点击和按键操作的结果:在 Google 首页按下 ctrl-S 尝试将页面保存为 HTML 文件,而在 Google Docs 网页中使用相同的快捷键则会将项目保存到你的云存储中。只有因为有一个负责协调所有部分的类:状态管理器,才能实现这一切。状态管理器是 事件驱动的:当某些动作(如按下某个键或右击)发生时,它们会收到通知并决定如何响应。

让我们来看一些事件。清单 12-1 展示了 PyGame 如何使用 pygame.event 类将事件发送到你的程序。

import pygame, sys
import state_manager as state
❶ for event in pygame.event.get():
  ❷ if event.type == pygame.QUIT:
        `--snip--`
        sys.exit()
    elif event.type == pygame.MOUSEBUTTONDOWN:
      ❸ state.handle_click(event)
    elif event.type == pygame.KEYDOWN:
 pygame.event.clear(None)
        state.handle_keydown(event)
    elif event.type == pygame.KEYUP:
        state.handle_keyup(event)
  ❹ elif event.type != pygame.MOUSEMOTION:
        print(event.type)

清单 12-1:处理 PyGame 事件

pygame.event 类包含一个事件队列,记录所有在连续的 get 请求之间发生的事件。get 调用的结果是一个事件对象列表,我们将遍历这些对象 ❶。事件的顺序通常(但不总是)是用户执行它们的顺序。例如,用户按下 ctrl-S 键盘快捷键保存项目时,会在队列中触发一系列事件,表示用户按下了 ctrl 键、按下了 S 键、释放了 S 键,最后释放了 ctrl 键。

每个事件对象都有一个类型字段,帮助你了解发生了什么。我们关心的有五种主要事件类型。pygame.QUIT ❷ 是一个特殊事件,应该触发应用程序的关闭代码,并最终通过干净地退出应用程序(不留下未使用的文件或打开的资源)来结束程序。其他类型的事件名称也非常直观:pygame.KEYDOWNpygame.KEYUP 事件分别在用户按下或释放键盘按键时触发。类似地,pygame.MOUSEBUTTONDOWN 事件表示用户点击了鼠标按钮 ❸。状态管理器使用这些事件来决定何时以及如何在应用程序状态之间进行切换。

PyGame 事件队列中有很多事件。例如,pygame.MOUSEMOTION 事件在用户移动鼠标指针时会多次触发。你可以通过检查事件类型是否不等于(!=)不需要的事件类型 ❹ 来过滤掉不需要的事件。在开发过程中,将事件打印到控制台可以帮助你识别你可能想要编写代码的事件,例如额外的键盘快捷键。这时使用类型过滤非常有用,可以减少输出消息的数量,如 清单 12-1 所示。

现代应用程序如网页浏览器和操作系统可以有数百个甚至更多的状态。此外,每个状态还可以有子状态,即在单一状态内存在的不同选项,例如在绘图状态下的红色或黑色背景屏幕。应用程序越复杂,就越需要时不时地回顾你的工作流图。开发是一个迭代过程,确保随着项目的进展,你能够捕捉到所有主要的状态非常重要。我不会详细讲解状态管理器中的所有代码,而是会展示一些驱动交互的关键元素。例如,我不会向你展示处理每个按键输入的逻辑,而是会展示如何通用地处理两种键盘输入类型(KEYUPKEYDOWN)。然后,你可以使用项目资源文件夹中的文档AGP_solver_API.pdf深入了解每个函数的具体实现。

Listing 12-2 中的代码展示了handle_keydown函数的框架。

def handle_keydown(event):
    global shifted
    global controlled
  ❶ if event.key in [303, 304]:
        # Shift key depressed.
      ❷ shifted = True
        return
  ❸ if event.key == 306:
        controlled = True
        return
  ❹ if event.unicode == "z":
      ❺ undo()

Listing 12-2:在状态管理器中处理KEYDOWN事件

该函数将pygame.event对象作为唯一参数。它需要这个对象,以便可以确定按下的是哪个键,并作出相应的响应。有几种方法可以检查事件的值。首先,通过检查event.key属性是否在一组值列表中❶,你可以将相同的代码块应用于多个输入值。这些值是键盘上每个键的数字标识符,也称为键的扫描码。在这种情况下,303304分别对应左侧和右侧的 Shift 键。如果按下任意一个,它将触发将shifted变量设置为True的代码❷。如果你只关心一对键中的某一个键,可以将event.key参数与单一扫描码进行比较❸。在这种情况下,306对应左侧的 Ctrl 键。按下右侧的 Ctrl 键不会触发将controlled变量设置为True的代码块。注意,shiftedcontrolled这两个变量都是全局变量,这意味着即使函数返回后,它们的值也会在应用程序中持续存在。这使得我们能够知道用户是否输入了像 ctrl-S 这样的双键组合,而这需要两次调用handle_keydown才能实现。PyGame 还有一个内置的函数pygame.key.get_mods,用于判断像 Ctrl 或 Shift 这样的修饰键是否在键盘组合中被按下;你应该探索它,以改进 Listing 12-2 中的代码。

判断是否按下了某个特定键,有时如果你将event.unicode属性与该键的字符串字面量进行比较,而不是使用键的扫描码,那么代码会更容易理解。在这个示例中,我们将该属性与字符串"z"进行比较❹;如果值匹配,就会调用undo函数❺。

清单 12-3 中的 handle_keyup 函数较短,因为通常我们关心的键的释放时机较少。

def handle_keyup(event):
    global shifted
    global controlled
 if event.key in [303, 304]:
        shifted = False
        return
    if event.key == 306:
        controlled = False
        return

清单 12-3:在状态管理器中处理 KEYUP 事件

这个函数的代码与清单 12-2 中的代码相反。我们检查 event.key 参数,看看用户是否释放了任意一个 Shift 键,如果是,就将 shifted 键设为 False。否则,我们检查左 Ctrl 键是否被释放,如果是,则将控制变量设为 False。Z 键触发一次性事件,这意味着你不需要担心用户是否释放了它。

状态管理器有一个类似的函数,名为 handle_click,用于处理鼠标点击事件,如清单 12-4 所示。

def handle_click(event):
  ❶ clicked = check_clicked_existing_vertex(event.pos)
  ❷ in_room = check_clicked_within_room(event.pos)
  ❸ if event.button == 1:
        `--snip--`
      ❹ left_click(event, clicked, in_room)
    elif event.button == 3:
        `--snip--`
      ❺ right_click(event, clicked, in_room)

清单 12-4:在状态管理器中处理 MOUSEBUTTONDOWN 事件

这里的概念与之前的清单相同,但逻辑需要更长,以处理判断点击了哪个按钮,点击发生时指针的位置,以及按钮点击时指针所在区域内是否有其他对象。check_clicked_existing_vertex 函数 ❶ 将鼠标的位置(存储在鼠标事件的 event.pos 参数中)与项目中所有顶点的列表进行比较。用户很难准确点击一个顶点的位置,所以我们给他们留有一定的误差空间,称为 ε(epsilon)。当前的 ε 值为 3 像素。如果指针距离顶点的距离在 ε(3 像素)范围内,返回该顶点的数据。EPSILON 常量在章节的附加材料中 state_manager.py 文件的第 35 行定义,而 check_clicked_existing_vertex 函数的代码从第 634 行开始。

类似地,check_clicked_within_room 函数 ❷ 用来检查指针是否位于任何多边形形状内。事件及与点击对象相关的信息(如果有的话)会根据点击了哪个按钮,传递给相应的函数。某些鼠标有不同的按钮配置,扫描码会根据你的电脑使用的制造商和驱动程序有所不同。你可以在点击不同按钮时使用 print(event),让 PyGame 为你识别它们的扫描码。对于生产环境中的应用,你应该使用 PyGame 内置的键盘字面量,例如 pygame.key.K_a,而不是扫描码,这样可以提高移植性。

在列表 12-4 中,如果event.button1 ❸,它表示用户点击了左键,因此我们调用left_click函数 ❹。如果event.button3,则表示用户点击了右键,在这种情况下,我们调用right_click函数 ❺。这两个函数都接受event对象、clicked顶点和room(如果存在)。每个函数都使用这些细节来确定如何通过大量特定的逻辑检查来更新内部状态。例如,实施在顶点上进行 Shift + 左键点击时的特殊删除响应,需要left_click函数首先检查shifted全局变量是否设置为True。如果是,状态管理器将从顶点列表中删除传递给它的顶点,从而更新内部状态。如果shifted变量是False,状态管理器将根据是否点击了房间、顶点或两者都没有,遵循不同的逻辑分支。正如你所想,这些函数的逻辑很容易变得非常长且复杂。

像我们在过去三个列表中所做的那样,处理事件和管理状态是状态管理器代码背后的核心概念。当你扩展你的实现时,你将继续向handle_keydownhandle_keyuphandle_click函数中添加逻辑,以实现所有不同的用户交互,例如绘制房间并向其添加障碍物(将在稍后的“添加图形用户界面”部分中讨论)。

通过并行处理加速安全性

过程并行性是一个包含许多细节的大话题,但简单来说,它意味着将需要完成的工作分配给多个工作人员。如何实现这一点被称为分工合作,这也是一个争议的话题。例如,假设你是一个教师,需要批改 100 份学生试卷,每份试卷有 20 道问题。幸运的是,你有四个助教来帮助你,总共有五个工作人员在批改试卷,因此你们每个人可以批改 20 份试卷。其好处是你们可以同时批改五份试卷,而不仅仅是一份。

另一种选择是让你们每个人选择四个问题进行评分。你们先拿到第一份试卷,评分你们的四个问题,然后把试卷传给下一个人,让他们评分他们的四个问题,以此类推。在这种情况下,每个工作人员至少接触到每份试卷一次,但每次的时间较短。这种分工的好处是它使每个工作人员能够集中精力处理他们最擅长的工作。想象一下,如果其中一位助教是机械工程的专家,另一位则专攻化学。通过让每位专家处理他们擅长的领域,你可以通过利用他们各自的能力来最大化过程的速度。

线程并行性

更正式地说,Python 有两种主要的并行性方法:线程并行性和处理器并行性。线程并行性发生在主应用程序打开一个共享其资源(如内存空间)的子应用程序时(docs.python.org/3/library/threading.html)。这就像是有一个统一的答案卡,所有批改试卷的人都能查看(想象它贴在房间的墙上)。每个批改员代表一个线程,答案卡是他们都可以访问的共享资源。在大多数 Python 版本中,线程技术上不是并行性的,因为每次只有一个线程可以执行命令(由 Python 解释器控制)。这相当于让每次只有一个人可以批改答案。虽然如此,由于线程切换非常快速,几乎是同时发生的,所以大多数开发人员(包括我)仍然将其视为并行。

线程可以通过几种不同的方式创建,但最常见的一种是创建一个类的多个线程,如清单 12-5 所示。

import threading, os, Image
❶ class DisplayAGP(threading.Thread):
    open_image = None
  ❷ def set_file(self, bgd_file):
        self.open_image = bgd_file

  ❸ def run(self):
        file, ext = os.path.splitext(self.open_image)
        im = Image.open(self.open_image)
      ❹ im.show()
        return

清单 12-5:用于显示并发图像的线程并行性

这段代码定义了一个名为DisplayAGP的类,允许应用程序同时显示多个图像,同时仍然允许主应用程序在后台执行其他工作。所有计划在线程中使用的类需要扩展threading.Thread类❶,并包含一个run方法❸,该方法包含在线程上下文中执行的逻辑——在此例中,即打开特定的图像文件并通过im.show❹显示它。你可以添加更多类方法,比如set_file方法❷,作为在运行线程之前配置每个线程的手段。

要并发显示名为image_files的图像文件列表,你可以使用清单 12-6 中的代码。

for fp in image_files:
    t = DisplayAGP()
    t.set_file(fp)
    t.daemon = True
    t.start()

清单 12-6:使用DisplayAGP类并发显示图像

我们从遍历文件位置列表开始。对于每个位置,我们创建一个DisplayAGP类的新实例。然后,我们调用set_file方法,并传入图像的位置,以便每个线程知道应该显示什么。将t.daemon属性设置为True告诉程序一旦线程使用start方法启动,就不等待该线程的结果。调用t.start实际上会触发DisplayAGP.run方法中的代码。一旦所有线程都被启动,主线程就可以继续处理其他工作。

### Processor Parallelism One of the major drawbacks to threading parallelism is that all the threads live in one application, which in turn lives in one section of your processor. Chances are your computer, phone, tablet, and probably even your toaster have multiple cores in their central processors. Threads load all the workers onto one core while the other cores sit idle (at least from Python’s perspective), losing all the advantages of modern computer architecture. *Processor parallelism* (also called *multiprocessing*) aims to address this limitation by enabling multiple copies of an application to communicate with one another ([`docs.python.org/3/library/multiprocessing.html`](https://docs.python.org/3/library/multiprocessing.html)). By splitting the work across separate instances of an application, you allow your processor to divide the work among all the cores. These cores are each independent little processors that can execute instructions on the same clock cycle as the other cores, making it the truest form of parallelism available in Python. Going back to our test grading analogy, this would be like giving each grader their own copy of the answer sheet to take home and work from. The graders would each get a copy of the resources they need to complete their portion of the tests (such as the answer key and a stack of questions), but they could also use whatever additional resources they have access to at home (such as a faster computer). Each grader operates in their own environment, completely independent of the other people grading papers. Once someone finishes grading their portion of the tests, they bring their graded answers back to you (the teacher, or main process), who then compiles the individual responses into final scores. Listing 12-7 shows how we can call a separate process to solve a multifloor project. ``` def mp_solve_floors(floors): ❶ ctx = mp.get_context('fork') ❷ q = ctx.Queue() p = ctx.Queue() procs = [] for i in range(0,len(floors)): ❸ proc = ctx.Process(target=mp_agp_solver, args=(p,q)) ❹ proc.start() procs.append(proc) results = [] for f in floors: `--snip--` ❺ p.put(work_item) while True: ❻ results.append(q.get()) for proc in procs: ❼ proc.join() return results ``` Listing 12-7: Using multiprocessing to solve floors concurrently The `mp_` preface in the function name `mp_solve_floors` is a standard way to denote functions that are designed to be part of a multiprocessing architecture. The function expects a list of `Polygon` objects representing the floors to be solved using the AGP solver we developed in the previous chapter. We first define the context that we’ll use to create the other processes. There are a few options, and the best one depends on your use case and the underlying system. The `fork` ❶ context will create a new process that has a copy of all the variable values in the main process. It’s also fairly stable across different underlying operating systems, making it a good choice for this application. Once the process has been created, the values change independent of the main process, so we need a way to communicate between processes. A common way to synchronize information between the main process and the worker processes is to create one or more shared queues, using the `Queue` class ❷. Typically, you want to create a queue for each direction of communication you want to support. In this case, the `p` queue will be used to send work objects to the worker processes, and the `q` queue will be used by the worker processes to send the solutions back to the main process. Once we have our queues, we loop over each floor in the list and create a solver process. The `target` parameter tells the process what function to call when the `start` method is called from the main thread ❹. The target for these processes is `mp_agp_solver` function ❸, which we’ll go over in a moment. Notice that we pass in the two queues to every `mp_agp_solver` process as it is created. These work queues are how the different subprocesses will communicate with the main process. The main process will send work to the subprocesses using the `p` queue and receive the results back using the `q` queue. Once all the processes have been instantiated, we add each floor to the work queue by calling `p.put(w)` ❺ (where `w` represents the piece of work being sent—in this case, a `Polygon` representing the floor of the gallery). At this point, the solvers can begin their work. As they complete the solution for each floor, they’ll place the output into the return queue. The main thread continues to look for results in the queue until it receives a solution for every floor (based on the solution count matching the floor count). When it finds a result in the queue, it appends it to the `results` list ❻. Once all the results are received, the main process loops over all created processes and calls the `join` ❼ function, which essentially ends the process’s execution. Next, let’s cover the changes necessary to the solution code to make it pull the work from the queue. Listing 12-8 shows the `mp_agp_floorplan` function that’s called as the target of each subprocess. ``` def mp_agp_floorplan(p, q): work = None while work == None: floor = p.get() `--snip--` q.put(solution) ``` Listing 12-8: Modifying the solver for use in multiprocessing The major change to the solution code from Chapter 11 is that we now add a `while` loop that tries to continuously get a floor to solve from the incoming queue `p` with `p.get`. Once the work object is received, the snipped portion of the code performs the steps for solving a floor plan we defined in the previous chapter, including converting the polygons to a graph, tessellating the floor shape, applying the greedy coloring algorithm, and so on. Once the floor plan has been solved, the graph object, Triangle result, and the coloring solution are packaged into a dictionary called `solution`, which is passed back to the main process using the outgoing queue with `q.put`. Using parallelism to speed up your application and allow concurrent operations is an excellent way to move from the concept phase to a full-fledged application: most users have come to expect snappy performance. To get the most out of Python’s parallel processing options, you should read the documentation for both the threading and the multiprocessing libraries. Thankfully, the developers of the multiprocessing library (the later of the two) had the good sense to model its API after the threading library, which was already very popular. ## Adding a Graphical User Interface Perhaps the largest single change from a proof of concept to a minimum viable product is the addition of a graphical user interface (GUI). Most users expect to be greeted by some visual workspace when running a program, and adding one makes your program accessible to the general public. Unfortunately, designing and developing a GUI can get very complex very quickly; there can be hundreds of components like buttons, text, and images that need to be managed. Users have different sizes and types of screens, which means laying out visual elements properly requires lots of additional code that you must account for. Furthermore, graphic elements like buttons aren’t static. When you click a button, the program provides some kind of audio or visual feedback (such as darkening the button to make it look like it’s been pressed). While this isn’t a book on programming graphics in Python (a topic on which there are numerous tomes already), I couldn’t totally ignore the topic: the graphics code makes up a large percentage of the project’s total code base. Thankfully, PyGame has a collection of tools to help ease the pain. I’ll cover some basic examples here to keep the code short and understandable. ### Displaying and Managing Images in PyGame The `Display` and `Surface` classes make up the base of the graphics platform. The `Display` class handles interfacing with the user’s screen and contains parameters related to the video system as well as code to modify how the interface is displayed onscreen. The `Surface` class holds collections of elements that should be displayed together. Each copy of the `Surface` class is like a blank canvas. Typically, you’ll have one `Display` class and one or more `Surface` classes to handle different (visually distinct) sections of the application ([`www.pygame.org/docs`](https://www.pygame.org/docs)). Listing 12-9 shows the simplest example of using the `Display` class. ``` import pygame ❶ pygame.init() ❷ background = pygame.image.load("guggenheim.tif") ❸ screen = pygame.display.set_mode(background.get_rect().size, 0, 32) background = background.convert() running = True ❹ while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False ❺ screen.blit(background, (0, 0)) mid_x = screen.get_width() / 2 mid_y = screen.get_height() / 2 ❻ pygame.draw.circle(screen, (0, 255, 0), (mid_x, mid_y), 50) ❼ pygame.display.flip() ❽ pygame.quit() ``` Listing 12-9: Displaying graphics using the PyGame display module We start by initializing a PyGame application with `pygame.init` ❶. The `init` function performs a series of background steps that, among other things, let your system know Python needs to interface with the video display. We load the background image (in this case, the Guggenheim floor plan) using the `pygame.image.load` ❷ function. The `background` variable now holds a copy of the image data, which we can use to retrieve the width and height required to display the image with `background.get_rect().size`. We pass this information as the first parameter to the `pygame.display.set_mode` function ❸ to create a `Surface` object that’s the exact size of the background image. Before we can display the image, though, we need to convert it from the intermediate data format PyGame uses into a format that can be drawn to the screen faster. We begin the program loop ❹ by defining a sentinel named `running` that will remain `True` until the user executes the `pygame.QUIT` event. Everything inside the program loop is executed on each run, which allows us to update the graphic elements and create basic animations. We tell PyGame to draw the background image on the next update by calling the `screen.blit` function ❺ with the converted background image object and the location on the `Surface` to place the image (based on the location of the image’s upper-left corner). We can then place additional graphics on top of the background floor plan. We first add a circle to the display using the `pygame.draw.circle` function ❻. The circle doesn’t mean anything at the moment, but it does show how we can combine background images and shapes drawn with code. We pass the function the screen to draw on as the first argument. Next, we choose a color to draw with; you can use an RGB tuple (as shown in the example) or a hexadecimal color code. After that, we pass in the midpoint location (the center point for the circle) to tell PyGame where to place the drawing relative to the screen. Rather than hardcoding these values, we can calculate them relative to the size of the screen. We place the circle in the middle of the screen by taking half of the screen’s width and height parameters as the x- and y-coordinates, respectively. The benefit of using relative position is that we don’t need to change the location of objects manually if we resize the screen. The drawback is that the logic for laying out lots of graphics can become fairly long and tedious to develop. Once we’ve laid out all the necessary graphic elements, we call the PyGame `display.flip` function ❼, which updates the whole screen area with any graphic changes. At this point, the whole loop then starts over. The loop continues until the user executes the `pygame.QUIT` event, which sets `running` to `False`; on the next loop check, the `while` loop is exited and the `pygame.quit` function is called ❽. Figure 12-2 shows the result of running the code in Listing 12-9. ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/math-sec/img/f12002.png) Figure 12-2: Drawing graphics onscreen with PyGame This figure shows the loaded floor plan for the Guggenheim museum along with a large, black circle (which will be green when you see it onscreen) in the center of the image. The circle is on top of the floor plan because of the order in which we drew the items. Of course, drawing a circle in the middle of the screen isn’t very useful, so we’ll combine the positional information from `MOUSE_CLICK` events with the ability to draw shapes onscreen for more advanced functionality. I highly recommend you dive into the `Display` and `Surface` class documents, as we’ve only scratched the surface here. ### Organizing Graphics with Sprites and Layers The problem with the previous method of drawing to the screen is that we’re drawing all the graphics directly on the same surface, which means that if we want to remove the circle at some point later in the code, we’d need to clear the screen and then redraw the entire background. You can imagine how this would scale. If you have dozens of components onscreen, you’d need to redraw each of those as well. Drawing and redrawing the screen can become a computationally expensive task, which will make the program appear choppy. A better method is to split up the graphics into individual components that can be displayed, removed, or changed without the need to modify the entire screen. To do so, we use a special class called a sprite, which you might be familiar with if you’ve ever worked with animation software. A *sprite* is a combination of the visualization of an element and the code to interact with it. For example, when a user draws a polygon representing a portion of a gallery (called a `Room` in the code), the `Polygon` is placed in a `Sprite` object that adds functions for showing or hiding the room on the display, as well as code to add obstacles (holes) to the room. Listing 12-10 shows a simplified version of the `Room` sprite class. ``` ❶ class Room(pygame.sprite.Sprite): color = (0,0,255) # Default to blue WHITE = (255,255,255) def __init__(self, vertices, screen_sz): super().__init__() ❷ self.vertex_list = vertices ❸ self.surface_size = screen_sz def init_surface(self): self.screen = pygame.Surface(self.surface_size) ❹ self.screen.fill(WHITE) ❺ self.screen.set_colorkey(WHITE) ❻ pygame.draw.polygon( self.screen, self.color, self.vertex_list, 0 # Filled polygon ) ❼ def clear_surface(self): self.screen.fill(WHITE) self.screen.set_colorkey(WHITE) ``` Listing 12-10: Creating a custom sprite class for `Room` polygons Every custom sprite class you write begins by extending the `pygame.sprite.Sprite` class, either directly, as shown here ❶, or by extending another class derived from the original `Sprite` class. The rest of the class definition is identical to other classes. You can assign class attributes and use the `__init__` function to customize each instance of the class. In the `__init__` function, we set the instance’s `vertex_list` attribute (the exterior points of the polygon to be drawn) ❷ and the `surface_size` attribute ❸ (the size in pixels of the surface used to display the drawn polygon). The `init_surface` function creates the actual `Surface` object in the attribute named `screen`, which will hold the drawn polygon data. By default, drawing a polygon on a screen will result in a solid background around the exterior of the polygon shape. When laid over the background image, this additional color will block portions of the floor plan from view, which is no good. We can make the background transparent by filling the surface with a color (white in this example) ❹ and then calling the `set_colorkey` function ❺ with the same color. The `set_colorkey` function makes the pixels that match the key color transparent. The effect in our application is to hide everything but the shape of the polygon so you can lay it over the floor plan without needlessly blocking sections. We draw the polygon to the surface using the `pygame.draw.polygon` function ❻. Note that the last parameter you pass in represents the line thickness to draw with. Setting the thickness to `0` tells PyGame to completely fill the polygon with the color you pass in. Make sure the color you pass to the `set_colorkey` function is different than the one used to draw the polygon, or you’ll remove the polygon as well. Finally, the `clear_surface` function ❼ uses the same `set_colorkey` trick to make the surface completely transparent, which is useful for temporarily hiding the polygon on the screen, without completely removing it. The `Room` class also has several functions that it inherits from the parent `Sprite` class, such as `add`, `remove`, and `update`, all of which allow you to control the sprite’s behavior after it has been created. Listing 12-11 shows how you can use the `Room` class to define a polygon overlay. ``` gallery_poly = [(20, 10), (20, 20), (55, 148), (145, 145)] `--snip--` poly_sprite = Room(gallery_poly, background.get_rect().size) poly_sprite.init_surface() screen.blit(poly_sprite.screen, (5,5)) `--snip--` ``` Listing 12-11: Using the `Room` sprite to display a polygon First, we define a polygon to display as a list of vertices named `gallery_poly`. You can take these points from user input (like a series of mouse clicks) or load them from a file, as long as the data is in (*x*, *y*) format. We define the sprite to hold the polygon data by calling the `Room` initialization method defined in Listing 12-10. We prepare the polygon for display by calling the `init_surface` function. Finally, we call the `blit` function on the main display `screen` and pass in the polygon’s screen attribute with `poly_sprite.screen`. Blitting the sprite’s screen onto the main screen tells PyGame to update the display with the information from the sprite. You can see the result of this code in Figure 12-3. ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/math-sec/img/f12003.png) Figure 12-3: Drawing a polygon with a sprite By combining the techniques of capturing user input, drawing directly to the screen, and using sprites to manage more complex graphic elements, you can come up with exquisitely detailed interactive displays to allow your users to complete their tasks. My advice for interface development is to start simply and build up, rather than trying to develop the whole UI at once. For example, start with the ability to draw a polygon using mouse clicks and keyboard shortcuts before adding graphic buttons and menus. When you’re ready to develop a more visually appealing UI (such as one with buttons and checkboxes for configuration options), check out another library named Phil’s Game Utilities (PGU), which is written to complement PyGame’s display classes with a large number of predefined visual components (like the aforementioned checkboxes). Using PGU, you can quickly develop context-aware menu options, animated buttons, and other polished visual elements users have come to expect. The downside is that it takes a lot of code to make the magic work. Showing and explaining the code for any nontrivial GUI would take more pages than I have to cover the rest of the art gallery project. ## Saving and Reloading Project Data We’ve discussed the user’s desire to work on complex projects over a number of working sessions. The desire to save and load work is almost universal when it comes to practical software. Python offers developers a wide variety of options for saving and reloading data. You have traditional methods, like databases and flat files, along with more modern options, such as pickle, a library for saving and loading Python objects. The exact method for saving and loading data has to be designed with your specific architecture in mind. For example, if you’re developing your application to run in the cloud, you might want to avoid saving data to flat files, or even local file-based databases like SQLite. At the very least, you’ll need to plan for storing the data in a manner that can be accessed from your cloud infrastructure (more on this in the next chapter). ### Saving to a Dictionary I chose to save the data as several files, grouped together inside a compressed (that is, zipped) file. The majority of the data will be stored in JSON files, which can easily be written from most dictionaries. To make life simple, I added a function called `to_dict` to each custom class, which returns all the data necessary to redefine the object from an empty copy of the class. Listing 12-12 shows an example of the `to_dict` function for the `Room` class. ``` def to_dict(self): ❶ return { "vertex_list": self.vertex_list, "name": self.name, "color": self.color, "floor": self.floor, "surface_size": self.surface_size, ❷ "obstacles": [o.to_dict() for o in self.obstacles] } ``` Listing 12-12: Creating a dictionary representation for the `Room` class This function returns a dictionary object ❶ that is designed to be JSON serializable. You can include nested objects that also have a `to_dict` function. The `obstacles` key ❷ will hold a list of dictionaries, each of which defines an `Obstacle` object to include in the room during reload. Converting a dictionary to a JSON string is simple using the json library (part of the standard libraries), as shown in Listing 12-13. ``` room = Room([(2,2),(2,4),(4,4)], (10,10)) `--snip--` room_str = json.dumps(room.to_dict()) with open("/tmp/project/floors.json", "w") as f: f.write(room_str) ``` Listing 12-13: Saving a JSON representation of an object We start by creating one or more objects. The call to the `Room` initialization creates a room shaped like a triangle with a display area of 10×10 pixels. We create a JSON representation of the room by calling `json.dumps` and passing in the dictionary representation of the `room` object. The `json.dumps` function returns a string that can be used to safely store or transport the data across a network. After creating a JSON string for every object we want to save, we write the strings into a file called *floors.json* inside a temporary directory. If you’re on a Windows machine, you’ll want to change the references to the *temp* directory to something like *C:\Users\Temp* depending on your version. At this point, you could incorporate the `DataSaver` from Listing 11-8 to save the `Triangle` files (if the user has created any tessellations), but since we already covered that code, I won’t show it here. Once all of the data objects and resources are saved as files in the temporary directory, we create a compressed archive file for easier storage. We can do this with the `make_archive` function from the `shutils` library like so: ``` shutil.make_archive("output_file.agp", "zip", "/temp/dir") ``` The first argument is a string that will be used to name the output archive. The second argument is the format to use when compressing the archive. Several options are supported, but the most universal, by far, is the *.zip* file format. The final argument is the path to the directory that holds the temporary files. At this point, you’ll have a compressed file called *output_file.agp*, which can be examined with any common unzipping tool (I personally like 7Zip because it’s free and cross-platform). Next, we’ll look at reloading the data from the JSON strings. ### Loading from JSON Files Reloading files works in the reverse order as saving. The program takes in a compressed archive, unpacks it to a temporary directory, and attempts to rebuild all of the objects contained within. Unpacking an archive to a directory is fairly straightforward, as shown in Listing 12-14. ``` from zipfile import ZipFile import os os.mkdir("/tmp/project") with ZipFile("output_file.agp", "r") as zipf: os.cwd("/tmp/project") zipf.extractall() ``` Listing 12-14: Extracting files from a previously created ZIP file We start by creating the temporary directory where the project files will live with `os.makedir`. We then open the archive by calling the `ZipFile` class with the path to the archive and the mode to use. Using the `with` construct allows us to keep the file data in memory as the variable `zipf` until the indented block of code is complete. Once the code block finishes, Python will automatically close the file. We change the working directory into the temporary project directory with `os.cwd`. Finally, we extract the files to the temporary directory by calling `zipf.extractall`. Once the archive is extracted, we can load the data back into a dictionary with the following code: ``` with open("/tmp/project/floors.json") as f: room_dict = json.loads(f.read()) ``` Essentially, this code block opens the *floors.json* file that was extracted in Listing 12-14 and then passes the contents of the file to the *json.loads* function. The result is a dictionary object that has the same structure and data as if you had called the `to_dict` function. The final step is to convert this data back into the proper object classes. To aid in the process, I recommend adding another function called `from_dict` to each class as you develop it. The `from_dict` function is the complement of the `to_dict` function in that it converts a dictionary-like object into object parameters. Listing 12-15 shows the `from_dict` function for the `Room` class. ``` def from_dict(self, p_dict): for k in list(p_dict.keys()): ❶ if k == "obstacles": for k2 in p_dict[k]: ❷ obs = Obstacle([]) ❸ obs.from_dict(k2) obs.init_surface() ❹ self.obstacles.append(obs) else: ❺ setattr(self, k, p_dict[k]) ``` Listing 12-15: Reloading a `Room` from a dictionary To simplify the process, there are only two cases of interest for the `Room` class: the case where we’re dealing with the list of obstacles and the case where we’re dealing with all other attributes. If the code is parsing the `obstacles` attribute ❶, we loop over all the obstacles in the data. For each, we create an empty `Obstacle` object ❷ and call the `from_dict` function ❸ on the data to recreate the object with all the appropriate attributes. Calling `init_surface` prepares the `Obstacle` object for display when the time comes. Finally, we append it to the room’s `obstacles` attribute ❹. In the event where the parameter being processed isn’t an obstacle, we simply add it as an attribute of the object being created using the `setattr` function ❺. Using the `setattr` function allows us to expand the definition of each class (for example, adding a new attribute to the `to_dict` function) without needing to update the `from_dict` function as well. Because we can update one function without impacting the other, we call these two functions *loosely coupled*. Loose coupling is a good goal to strive for in a production application because it reduces the amount of effort needed for ongoing development. We can use the `from_dict` function to recreate the `room` object from the file data with the following code: ``` room = Room([], (1,1)) room.from_dict(room_dict) ``` First, we create an empty `Room` object to hold the attributes; then, we call the `from_dict` function and pass in the dictionary `room_dict`. At this point the code is back to the same state it was in at the beginning of Listing 12-13. ## Running the Example Application I’ve included a working example of the application in the chapter’s supplemental materials. It includes the features we’ve discussed up to this point. You can use the application to play with solving your own floor plans, then dive into the code and start improving it! Once you’ve navigated to the project folder, you can see the application’s help screen with this command: ``` $ **python poly_draw.py -h** ``` The output will be a list of options like these: ``` `--snip--` Usage: poly_draw.py [options] Options: -h, --help show this help message and exit -r RESUME_FILE, --resume=RESUME_FILE Load Room shapes from a previously saved file -b BGD_FILE, --background=BGD_FILE Background PNG to load (if not resuming) -o OUT_FILE, --out-file=OUT_FILE File to save shapes to -e EPSILON, --epsilon=EPSILON Sets near-miss distance -f, --use-feet Convert answers from meters to feet ``` We’ve already covered the first option, `-h`, which prints out this helpful menu. The `-r` option allows you to resume sessions by passing in the location of a compressed project file created using the save feature we discussed earlier. The `-b` option allows you to set the base floor plan’s background image when you’re starting a new project. The `-o` option allows you to specify the filename to save shape data to when the save command is called (with the S key), and the `-e` option allows you to change the value the program will use for epsilon. If you have trouble clicking vertices, you can try increasing this number a few pixels at a time until you achieve the desired result. Turning it up too high can cause unexpected behaviors, though. Finally, the `-f` option tells the application to use feet as the unit instead of the default meters when scaling the solution. You can try the program out with a floor plan like the example of the Guggenheim as follows: ``` **$ python poly_draw.py -b** `guggenheim.tif` **-o myfloorplan** ``` Replace the reference to `guggenheim.tif` with the name of your file. The application will ask you to enter a name for the floor plan in the console. For now let’s call the floor “main.” After you press enter, the application will open the Guggenheim image and enter the scaling state. Click two places in the image to draw a line. The console will then ask you to input a length for this line. This is how the application will figure out the real-word distance of the scaled image. You can now start drawing the rooms on the screen by clicking around with the left mouse button. When you want to finish the room, connect the final point back to the initial starting point to create the closed shape of the room. When you click the starting point to close the room, the console will ask you to input a name for the room. Enter something like “Gallery” and press enter. You should see the inside of the area you traced out change to green. Finally, try saving your work with ctrl-S. You should see the console spit out a message like this: ``` Saving... Saving 1 floors. Saving 1 rooms. Successfully Saved: myfloorplan ``` If you list the files in the directory, you should now see a compressed file named *myfloorplan* that contains the floor and room data you just created. You can now safely exit the program using the X button at the top of the window. You can reload your previous save and begin work again with the following command: ``` python poly_draw.py -r myfloorplan -o myfloorplan ``` It’s actually best practice to change the `-o` filename by appending a version number between edits. This way, you can always go back to a previously working copy if something goes awry. There are many more functions in the example application and better ways to handle keypresses. There are also some incomplete features I’ve left for you to finish for practice. Read the code and play with the application to see if you can complete them. ## Summary We’ve covered a great deal of material in this chapter, but we’ve barely scratched the surface of user interfaces in Python and the multitude of events available in PyGame. You’ve seen how you can use these events to capture user input via the keyboard and mouse, but there are limitless ways you can expand on the concepts shown here to make your application more intuitive or enable power users (users that have a high level of familiarity and understanding of an application) to speed up their work. As mentioned, for a more visual interface, I encourage you to look into Phil’s Game Utilities, especially if you want your application to be accessible to the largest number of users. A polished visual display is key to gaining interest from less technical users. There’s an entire field of study devoted to understanding how people interact with systems called *human–computer interaction* *(HCI)* research ([`en.wikipedia.org/wiki/Human–computer_interaction`](https://en.wikipedia.org/wiki/Human–computer_interaction)). As a developer, you can use HCI research to quantify how users interact with your application and locate areas for improvement. As a security analyst, you can use knowledge of HCI to plan interface controls that make managing privacy and security more intuitive. One particularly good source on the topic is the book *Research Methods in Human–Computer Interaction* (Wiley, 2014).^(2) We also covered one possible way to save data between user sessions using JSON files. Saving the data in a human-readable format like JSON allows you to troubleshoot the save and load functions easily during development. We’ll discuss other potential options for saving the data in the next chapter, where we’ll also cover different options for distributing your application to the masses, including moving the application into the cloud.

第十五章:交付 Python 应用程序

一旦你觉得自己已经达到了最小可行产品的要求,就该开始关注交付管道了。交付管道定义了用户如何获得你的应用程序及其未来的更新。说实话,我通常在项目开始时就定义这些属性,因为它们能让某些开发决策变得更有利或更不利。例如,如果你决定将应用程序部署到云端,将数据保存为本地文件就不像计划将代码作为本地包交付时那么有意义。在本章中,我们将高层次地了解四种潜在的交付管道。每种方法都有丰富的资源材料帮助你交付项目,因此我将重点介绍每种方法的重要考虑因素、优点和缺点。

除了讨论每种方法的交付方面,我还会谈论卸载过程。MVP(最小可行产品)中人们常常忽视的一个部分是卸载功能。我认为,拥有一个良好、干净的卸载功能是成为优秀软件供应商的关键之一。你的卸载程序的目标应该是让用户不需要做任何清理工作。如你所见,有些方法比其他方法更容易实现这一点。

选择交付方法的一个主要影响因素是你是否计划通过应用程序获利,以及如何获利。例如,如果你想向用户收取订阅费,你可能希望跳到“通过云微服务分发”或“使用 PyArmor 授权”这两个章节,它们都能让你定义谁可以访问你的应用程序。缺点是这两种选择都不是免费的,因此,如果你不打算对应用程序的访问收取费用,使用 GitHub 通过设置脚本分发应用程序,或预打包应用程序及其所需的所有文件可能会更具成本效益。

使用设置脚本

分发 Python 应用程序最简单的选择是使用一个名为setup.py的特殊配置脚本,它为底层系统配置了运行代码所需的库和支持文件。如果你曾在手动下载 GitHub 仓库后安装过 Python 模块,那么你可能遇到过这种方法。更一般而言,使用这种方法打包的代码要求用户安装一个名为 setuptools 的库,它根据你在设置脚本中定义的结构来处理安装。你可以通过阅读 PyPi 文档了解有关设置脚本的结构和选项(pythonhosted.org/an_example_pypi_project/setuptools.html)。

使用这种方法的主要好处是你可以将项目托管到 PyPi,这样用户就可以通过 pip 工具轻松安装它。当你从 PyPi 安装一个项目时,pip 工具会从你定义的存储位置拉取适当版本的项目(通常是一个公开的 GitHub 仓库,虽然也有其他选择)。用户无需手动下载代码库或运行设置脚本,这些都在后台处理。

然而,这种方法也有一些明显的缺点。首先,利用这种方式很难对代码进行货币化。没有控制机制来阻止用户将源代码复制到其他机器上。也没有原生的方式来卸载已安装的代码,这意味着即使你找到了货币化的方式,也只能收取一次性费用,无法实施类似订阅计划的收费模式。其次,设置脚本依赖用户从命令行安装应用程序。如果你预期用户熟悉这个过程,这没问题,但对于普通用户来说,这并不是交付应用程序的最佳选择。最后,这种安装方式会对用户的底层系统进行更改,安装并配置软件包。虽然这种方式大多数情况下可以正常工作,但对系统进行任何更改都有可能导致损坏某些内容、与现有文件发生冲突等。如果用户没有在隔离的虚拟环境中安装应用程序,就会有很大的风险与用户安装的其他应用程序发生不兼容的库版本冲突。

使用这种方法安装的应用程序在卸载时会遇到一些问题,通常需要用户从系统中移除相关依赖和其他资源。如果你选择这种方式,我建议让用户使用独立的虚拟环境。如果用户没有在隔离的虚拟环境中安装应用程序,试图帮助卸载依赖可能会破坏系统上其他应用程序的正常运行。另一方面,如果用户确实将所有内容都安装在自己的虚拟环境中,卸载就像删除环境一样简单。

我建议对于开源应用程序和那些没有足够资源支持复杂交付管道的小型项目使用设置脚本方法。一旦你有了账户,你可以在几分钟内将模块和设置脚本部署到 PyPi。设置脚本也是理解更复杂部署(如云服务)的一个良好入口点,因为在某种程度上,所有这些方法都需要了解代码运行所需的依赖关系。总体来说,这是每个 Python 开发者都应该熟悉的可靠交付方式。

使用 Python 解释器进行打包

下一个选项旨在通过减少用户的工作量,解决设置脚本方法的一些缺点。其理念是将代码、支持文件和 Python 解释器打包到一个单独的档案中,并交付给用户。用户只需要将档案解压到他们系统中的一个目录,就可以准备好运行应用程序。

与设置脚本相比,这种方法能够带来更好的变现方式。通过将打包后的应用程序下载托管在网站后面,您可以向用户收取每个新版本的费用,或者您可以向该网站收取月度订阅费,其中包括对最新版本的下载访问权限。尽管没有任何东西可以阻止用户支付一次费用并永久保留该版本,但他们也有动机维持他们的账户,以便访问最新功能。

为了处理打包,我使用了 PyInstaller,这是一款免费的应用程序,帮助收集必要的文件,使您的程序独立运行,即无需配置底层系统即可运行。使用这种方法进行打包通常被称为冻结应用程序,因为它收集了系统中当前版本的所有依赖项和已安装的 Python 解释器的副本,然后将它们打包,使得包含的解释器只会使用那些打包的库来运行。这里的优点是,您不需要担心安装了哪个版本的包,或者它是否会与用户机器上的其他应用程序发生冲突。缺点是,如果在冻结应用程序后需要更新其中一个底层库——例如,为了减少某个依赖项中的安全风险——就需要发布补丁或新的应用程序版本进行分发。如果用户没有应用补丁或下载最新版本(这种情况非常普遍),他们的系统就会面临风险。

另一个缺点是大多数冻结应用程序的体积。为了确保内部代码正常运行,通常会将整个标准库与其他依赖项一起冻结。庞大的代码库和 Python 解释器意味着即使是简单的应用程序也可能最终达到几个兆字节。PyInstaller 尽其所能来最小化冗余,并且您可以配置它以进一步减轻体积,但最终,使用这种方法时总会有额外的冗余。

就像能量一样,复杂性并不会消失。将复杂性从用户身上转移到自己身上。为了使冻结交付方法有效,你需要为每种你想要支持的系统类型创建不同的包。例如,你可能最终会有一个名为agp_linux64_amd.tar.gz的包,适用于拥有 AMD 处理器的 64 位 Linux 系统用户,另一个名为agp_win64_intel.zip的包,适用于在 Intel 平台上运行的 64 位 Windows 用户,依此类推。为了打包每一个包,你需要访问一个操作系统副本,用于打包系统文件。在开发过程中,我使用 VirtualBox 配合每个操作系统的副本作为虚拟机,并预配置好合适的依赖项和 Python 版本。我喜欢这种方法,因为它允许我通过 VirtualBox-manager 应用程序和一些自定义脚本来自动化多个平台的构建过程(www.virtualbox.org/wiki/Documentation)。

对于 Windows,你面临一个独特的情况。在撰写本文时,一些必要的驱动程序受到微软许可证的保护。未经授权分发这些库到你的应用程序中,可能会违反微软的服务条款,甚至可能导致你因被认为造成收入损失而承担责任。需要注意的是,如果最终用户已经拥有这些库的副本(通常情况下是这样),那么将应用程序发送给他们就不会违反微软协议。可以说:何时以及如何为 Windows 提供预打包的 Python 应用程序是一个灰色地带。不要将此解释为法律建议;我不是律师。我建议你咨询你所在地区专门从事技术许可证知识产权争议的律师,他们能够帮助你避免任何法律风险。

如果你想为用户提供独立版本的应用程序,冻结你的应用可能是一个不错的选择。独立项目的好处在于它易于设置和从机器上删除——卸载操作可以像删除一个文件夹一样简单。在很多情况下,冻结的应用程序甚至可以从 USB 存储设备运行,这意味着你可以随身携带它,并且不需要在系统上安装代码就能使用!

使用云微服务进行分发

部署到云端对不同的人来说意义不同。你可以认为使用某些数据存储服务(例如亚马逊的 S3 或谷歌的云存储)托管前面消息中的包,并在同一供应商提供的虚拟机中托管网站,构成了一种云部署。的确,在那个时候,交付管道是云服务,但应用程序本身仍然会被用户下载并在本地运行,所以我不认为这是一种真正的云服务。

对我来说,云部署的大多数功能代码都运行在由服务提供商(如谷歌或亚马逊)托管的基础设施上,这意味着你的应用程序是以功能状态提供给用户,而不是将源代码发送给他们运行。在本章的其余部分,我将避免提到任何特定的服务提供商。全球两大云提供商——谷歌云平台(GCP)和亚马逊网络服务(AWS)——大致提供相同的功能,因此我认为讨论这些概念更有益。你可以根据这些概念,学习如何在你选择的特定服务提供商上应用它们。

用户通常会通过某种网站访问你的软件,而不是下载代码进行托管。虽然也可以拥有一个用户端应用程序作为与云结构交互的界面,但这种做法不太常见,因为它会增加已然复杂的过程的复杂度。代码被拆分成称为微服务的小块,每个微服务处理应用程序的一小部分。

图 13-1 展示了 AGP 项目的简化微服务架构。

图 13-1:微服务架构图

每个椭圆代表应用程序的一个小部分,它在虚拟机中运行,虚拟机仅具备足够的资源来执行该功能,然后在不再需要时消失。一个好的微服务部署的关键在于将功能清晰地分离到不同的服务中,并高效地管理服务之间的通信(图中的黑色箭头)。

在图 13-1 中,我将项目分为四个服务。用户界面被移到一个网站中,该网站可能使用 HTML5 进行交互式绘图,并使用 JavaScript 与其余的服务进行通信。JSON 是一个很好的通信协议选择,因为服务之间的通信大多是通过 HTTP 请求处理的,并且两种使用的语言(Python 和 JavaScript)都能轻松处理这种格式。数据管理服务包含保存和加载用户项目数据的功能。

许多微服务设计的一个限制是它们缺乏一个永久性的文件系统来提供文件。你可以通过创建一个持久存储位置来克服这一点,或者将存储位置设置为持久的数据库实例。事实上,凭借一些创造力,你可以使任何网络可访问的存储位置都适用于这个目的。无论如何,将数据管理服务置于其他服务和存储容器之间,意味着数据管理服务是唯一需要知道如何从云存储容器中读取和写入的服务。如果你决定以后迁移到不同的存储方式,你只需要更新一个服务。

图形构建服务包含了应用程序管理画廊图形表示的所有功能。它与用户网页界面(接收表示图形的 JSON 数据)和数据管理器(在完成后保存信息)进行通信。三角形求解服务包含了管理画廊多边形表示的功能,包括最终使用三角形库求解每个楼层的代码。它还与用户网页界面服务和数据管理器服务进行通信,处理代码的输入和输出。

Docker 非常适合微服务,因为它允许你配置每个虚拟机,仅包含运行服务代码所需的部分,这使得虚拟机创建速度更快,运行更安全。你可以使用 Docker 容器在大多数云服务提供商上定义这些微小的机器(docs.docker.com)。此外,你还可以使用像 Kubernetes 这样的容器编排平台,根据需要自动管理每个服务容器的创建和删除。

自动创建更多应用程序实例以提供给用户的过程叫做横向扩展。利用平台的横向扩展功能将使你的应用程序能够无缝适应处理需求的变化。你可以为每个服务单独定义规则,这意味着你只会扩展那些需要扩展的应用程序部分,其他部分则保持不变。例如,假设你的应用程序有 20 个用户同时请求不同楼层平面图的解决方案。在传统架构下,三角形服务必须处理所有 20 个请求,因此队列中的最后一个用户的等待时间会比第一个长。而使用横向扩展时,编排引擎会看到需求增加,并添加 19 个三角形求解服务的副本。这些副本会并行运行,因此所有请求都可以同时处理。另一方面,20 个用户使用一个网页服务器通常没有问题,所以你不会希望编排平台添加更多的用户界面服务副本。通过为每个服务单独配置自动横向扩展规则,你可以节省未来维护的时间。这是你可以利用的第三种并行形式:硬件并行性。它类似于第十二章 中讨论的进程并行性,只不过工作是分布在不同的机器上,而不是同一机器的不同核心上。

云微服务方法可能是最初实现起来最复杂的,但其带来的好处也很多。我们已经看到,它的灵活性可以允许快速迭代并减少维护时间。另一个好处是,你可以更轻松地实现应用程序的货币化,且掌控度更大。由于源代码从未发送给终端用户,他们必须保持账户才能继续访问服务。如果他们决定停止使用该服务,通常也没有什么需要卸载的东西。用户所需要做的就是终止账户,服务即告消失,从这个角度来看,这是最干净的退出方式。

你可以根据需要运行多个版本的应用程序以服务用户。大多数云服务提供商提供应用层流量路由功能,使你能够根据定义的规则智能地将流量引导到应用程序的不同副本。Kubernetes 也有一些流量路由功能,可以用来实现相同的效果。你可以使用流量路由功能,在将更改分发给所有用户之前,有选择地进行 Beta 测试,或者定义单独的测试版和生产版(称为A/B蓝绿测试)。^(1)

最后,更新完全由你掌控。用户在你更新后会自动访问到最新的生产版本。通常,微服务是分阶段构建的。每个项目的阶段可能有所不同,但大致上遵循以下几个阶段:代码推送、持续集成测试、Docker 容器构建,最后是服务部署。代码推送你可能已经非常熟悉了。它是在你将一些更改推送到代码仓库时发生的(例如,使用命令git push)。推送代码会触发持续集成测试。这些测试旨在确保你的更改不会引入常见的 bug(circleci.com/blog/proactive-integration-testing)。

主要的缺点是与构建和维护确保应用程序无缝运行所需的服务网络相关的金钱和时间成本。每个云服务提供商都有自己独特的实现方式,而且这些组件本身要求你了解像 Docker 和 Kubernetes 这样的辅助应用程序。如果你决定将应用程序部署到云端,你将需要花些时间学习你所选择平台的独特性。拥有一支开发团队来支持你的云部署工作也是有益的。

你不能指望在构建一个稳固的云部署管道时,成为所有不同组件的专家。在我的职业生涯中,我很幸运与一些领域内最优秀的云工程师共事,我学到的就是强大团队的价值,团队由不同的专家组成。拥有一个专注于架构的人员、另一个专注于用户界面的人员,以及第三个编写主要服务代码的人员,意味着工作可以更快完成;这就是并行开发。拥有团队还使每个人能够专注于他们最擅长的领域,并使你的项目受益于更多的专业知识。当然,管理一个开发团队也带来了许多问题:个性冲突、交付日期延误等等。决定使用开发团队意味着你还需要一个人来负责团队成员之间的沟通和协调(称为项目经理)。云部署是所有现代软件即服务(SaaS)公司的核心,因为其长期的收益远远超过了最初的开发成本。当然,云部署可能不是仅有少数计划用户的小项目的最佳选择。

与 PyArmor 的许可

下一个方法有点特别。PyArmor 是一个命令行工具,用于混淆 Python 源代码。它在概念上类似于独立应用程序,因为你仍然会向用户交付一个可执行文件,但 PyArmor 尝试确保你的 Python 应用程序只在经过批准的机器上保存和运行,旨在保护你的知识产权,并帮助你将交付给用户的可执行文件进行货币化。混淆是隐藏代码的结构和操作的过程,从而使得应用程序在没有适当的去混淆技术时无法运行。混淆的一个例子可能是将字符串 "Hello from PyArmor" 改为类似于 "H7ejl8l3ocb1fRr4osm9blPjy9Afr4mvo0rp" 的内容。该应用程序会混淆常量和字面字符串以及每个函数的运行时代码。如果有人试图读取你的源代码,无论是在静态文件中还是在内存中,他们将遇到一堵乱码的墙。

混淆应用程序的源代码还使你能够将应用程序绑定到单一的机器上,并且能够远程使应用程序过期。你的代码被隐藏在一个启动应用程序后面,该应用程序包含适当的去混淆技术。PyArmor 启动脚本检查用户安装应用程序时创建的许可文件。该许可文件使用一些唯一的机器属性,确保每次启动时都在同一台机器上运行。

你还可以定义一个许可证服务器,管理每个许可证的有效性。在每次启动时,你的应用程序会调用许可证服务器并传递它的许可证标识符。然后,服务器可以返回一个经过身份验证的消息,告知应用程序是否应该允许自己执行。当然,这依赖于用户每次运行软件时都能访问网络,这可能适用于你的项目,也可能不适用。

混淆不应与加密混淆,它显然不能提供强加密方案的安全性。使用加密时,你可以通过某种形式的秘密信息来提供数学上的安全证明。这个秘密越大,越难以猜测,应用程序就越安全。另一方面,混淆更像是伪装:它旨在防止通过逆向工程或绕过许可限制的简单尝试。然而,一旦有人理解了代码是如何被混淆的,他们总能逆转这个过程。回到我之前的例子,如果你更仔细地检查第二个字符串,你可能会意识到我所做的只是将随机字符插入到短语的字母之间。接下来,我将空格替换为常量字符b。通过逆转这个过程,将偶数位置的b字符替换为空格,并去掉每隔一个字符,你就能把混乱的字符串恢复成原始短语。更糟糕的是,处理去混淆的启动脚本本身无法被混淆,这意味着所使用的技术对任何希望查看的人都是公开的。(如果它能被混淆,你将会创造一个反馈循环,因为你需要一个去混淆器来去混淆去混淆器。)借用锁匠世界中的一句话:“足以让诚实的人保持诚实。” 根据混淆的级别,这可能只能让一位优秀的逆向工程师浪费几个小时。

尽管有一些明显的限制,我并不会完全否定 PyArmor。如果你计划通过独立包交付方式来实现盈利,PyArmor 可能是一个不错的补充,因为它确实为这个过程增加了一些控制和监控。你不能保证你的控制不会被绕过,但它肯定比没有应用混淆和许可的独立应用程序更不容易被绕过。PyArmor 的另一个潜在用途是跟踪活跃用户的数量。即使你不打算从你的项目中盈利,拥有许可证也能通过查看哪些许可证已经签到(意味着应用程序已经启动)来估算用户数量。随着你的项目受欢迎程度的提升,你可以利用这些估算来吸引投资者的兴趣,或者有可能将项目出售给更大的 SaaS 提供商。

开源交付

没有什么比开源交付选项更合适的结尾了。迄今为止,交付你的项目并回馈社区的最简单方法就是公开授权你的项目源代码,让每个人都能使用。开源软件许可证本身就促进了协作和共享,因为它们允许其他人对源代码进行修改,并将这些修改纳入自己的项目中。通过将你的代码库托管在公共 GitHub 仓库(或类似平台)上,你可以享受众包开发的好处,获得潜在用户的反馈,减少托管成本,等等。开源项目促进了多元化视角的协作。来自全球不同地区的人们可以汇聚一堂并做出贡献。人们解决问题的方式各异,因此,来自不同背景的人们的贡献可以将你的项目推向一个你自己,即使是有传统开发团队的情况下,也无法达到的功能水平。

一个常见的误解是,“开源”就意味着你无法从应用程序中获利。这完全不正确!虽然大多数开源项目的起点并非为了盈利,但维护一个大型开源项目——比如 Kubernetes——确实是一项巨大的工作!它需要几个全职开发人员,而这些开发人员大概率希望能从他们的工作中获得报酬,因此开源项目往往会衍生出成功的公司。项目通常会与云交付选项结合开源,以提供免费版本和付费版本。公司会支付这些云托管版本的费用,从而减少需要在内部维护的系统数量。Red Hat——一个维护最受欢迎的企业级 Linux 发行版的公司——就是遵循这一模式的大型开源公司之一。虽然 Red Hat 继续提供许多应用程序的开源版本,但它也提供付费定制和远程支持来维持业务。简而言之,选择开源代码通常能减少你的压力,并推动项目取得更好的成果,但你并不需要牺牲盈利能力。在考虑应用程序交付方法时,我强烈推荐你研究开源路线。

总结

将你的应用程序部署供一般用户使用,可能看起来像是一个独立的项目。正如你所看到的,有几个因素应当影响你选择的方法。这些因素包括你计划服务的用户数量,以及你是否打算收费。正如我在章节开始时所说,你应该在开始项目时就确定交付平台的基本概念。一旦你决定了如何交付应用程序,你可以根据这个选择来影响你其他的开发决策,比如可供你的代码使用的存储选项。

到此时,你应该已经对可用的选项以及每个选项的优缺点有了一个大致的了解。你可以从这些基础出发,深入了解最适合你需求的过程。不管你选择哪种方法,记得从用户的角度以及开发者的角度思考。对用户要友好,提供直观的方式来安装、管理和删除你的应用。

互联网上充满了学习软件部署计划的资源,从非常简单到极其复杂的都有。也有一些非常棒的书籍涉及各种部署技术,如 Docker 和 Kubernetes(bookauthority.org/books/new-continuous-delivery-books)。我建议从小规模开始,逐步提高。如果你从未使用过 Git,直接跳入云部署将会让你感到沮丧。可以从像 PyPi 这样的工具入手,它将帮助你磨练你的仓库管理技能。一旦你对每个基本组件都感到熟悉,你将更有准备来应对大型云部署过程。

到这里,我们已经完成了本书的内容!如果你已经掌握了所有的概念和项目,我向你表示祝贺!希望你能感受到应用数学在安全工具中可以发挥的作用。如果你从本书中获得任何收获,我希望那就是:你可以仅凭基本的数学知识和编程理解来应对看似复杂的研究课题。像人脸识别、隐私监控和社交网络分析等话题现在可能占据了所有的头条,但在更广泛的安全领域中,仍有大量未解的研究问题,而这些问题都可以从像你这样的才华横溢且专注的研究人员中受益。如果有某个特定的领域引起了你的兴趣,我鼓励你将学到的概念应用到那个领域中。本书所涵盖的领域都能极好地适应多种兴趣方向,当你将它们结合在一起时,可以实现非常强大的分析工具。

在将安全应用到现实世界中时,最可怕的部分就是在不确定性面前做出决策,毕竟一个错误可能会付出生命的代价。像前几章所展示的分析工具,能够让我们从不同的角度审视世界,并做出最有依据的决策。你可能无法完全消除不确定性,但你可以将其对自己和周围人的影响降到最低。记住:安全不仅仅是一份工作或职业道路,它更是一种理解世界的方式。未来的安全应用将依赖于准确地收集、解读和回应来自我们物理和数字环境的数据,以帮助我们更好地理解世界。

第十六章

封面

第十七章:安全数学

从图形与几何到空间分析

作者:丹尼尔·雷利

第十八章

安全数学。版权© 2023 年,Daniel Reilly。

版权所有。未经版权所有者和出版商的书面许可,本书的任何部分不得以任何形式或任何方式复制或传输,包括复印、录音、或通过任何信息存储或检索系统。

初版

27 26 25 24 23 1 2 3 4 5

ISBN-13: 978-1-7185-0256-7(印刷版)

ISBN-13: 978-1-7185-0257-4(电子书)

出版人:William Pollock

主编:Jill Franklin

生产经理:Sabrina Plomitallo-González

生产编辑:Miles Bond

开发编辑:Alex Freed

封面插图:Gina Redman

内部设计:Octopod Studios

技术审阅:Ricardo M. Czekster

文案编辑:Rachel Monaghan

排版设计:Jeff Lytle,Happenstance Type-O-Rama

校对:James M. Fraleigh

索引编制:BIM Creatives, LLC

如需了解有关发行、大宗销售、企业销售或翻译的更多信息,请直接联系 No Starch Press®,邮箱:info@nostarch.com,或:

No Starch Press, Inc.

245 第八街,旧金山,加利福尼亚州 94103

电话:1.415.863.9900

www.nostarch.com

美国国会图书馆控制编号:2023002853

No Starch Press 及其 No Starch Press 标志是 No Starch Press, Inc.的注册商标。文中提到的其他产品和公司名称可能是其各自所有者的商标。我们不打算在每个商标名称前加上商标符号,而是仅在编辑性地使用这些名称,以便商标所有者的利益,并无侵犯商标的意图。

本书中的信息以“按原样”方式发布,不提供任何担保。尽管在本书的编写过程中已采取了所有预防措施,作者和 No Starch Press, Inc.对因本书中的信息而直接或间接引起的任何损失或损害,不承担任何责任。

第十九章

致我的家人。你们是我所做一切的原因。

第二十章:关于作者

丹尼尔·雷利(Daniel Reilly)是一位安全研究员、分析师和顾问,现居美国华盛顿州西雅图。他在安全领域工作已有 20 年,其中超过一半的时间用于为小型企业开发和管理运营安全。

关于技术审阅者

里卡多·M·切克斯特(Ricardo M. Czekster)是英国伯明翰阿斯顿大学计算机科学系的助理教授,他专注于通过定量分析研究系统的可靠性和网络安全。在他的职业生涯中,里卡多一直致力于使用安全驱动工具以及建模和仿真的实际应用,他对跨系统建模对手、网络威胁情报、自动化风险评估、威胁建模和入侵检测等方面很感兴趣。他曾在全球多家研究实验室和机构工作,包括普林斯顿的西门子、爱丁堡的计算机科学基础实验室,以及纽卡斯尔的安全与弹性系统。

第二十一章

当今的科学家用数学代替了实验,他们在一个又一个方程式中徘徊,最终构建出一个与现实无关的结构。

—尼古拉·特斯拉

第二十二章:致谢

一本像这样的书,离不开几十个人的努力,无论是直接还是间接的。当我决定在 2016 年出版我的作品时,我完全没有想到会有这么多人参与进来,帮助我完成我开始的事情。然后,我开始思考这些年来,我真正应该感激的那些在我职业成长中提供帮助的人们。这让我意识到,列举每一个人的名字几乎是不可能的,所以让我先对过去 20 年来所有为我的项目贡献过的人表示感谢。黑客社区一直以来都是开放共享知识的社区。正是通过这种共享和鼓励,我才能够发展出成为安全分析师所需的技能。像 DEF CON 和本地 BSides 等会议的组织者们,值得特别感谢他们为这些活动付出的努力。参加这些活动是与社区成员建立联系、相互学习的机会。这些活动背后也往往是混乱的旋风,而组织这些混乱可不是一件小事!我还要感谢 Frank Lloyd Wright 基金会以及来自艺术家权益协会的 Daniel Trujillo,感谢他们帮助我处理书中使用的古根海姆图片的版权许可。我从未涉足版权工作,但你们让整个过程变得异常简单,我非常感激!

我还想对 No Starch Press 的团队表示衷心的感谢。你们为将我的涂鸦变成一本书付出了如此多的时间,真是令人惊叹。我以前从未真正理解出版一本 No Starch 书籍需要付出多少工作,而现在我明白了,我对我的收藏更加珍惜了!Athabasca 和 Alex,你们花了如此多的时间修订我的草稿,我可以真诚地说,这本书没有你们就根本不可能存在!Jill、Miles 和 Rachel,你们让我学到了如此多关于编辑过程的知识。我真无法想象还有什么更好的团队,能够在整个过程中支持我。

在我的职业生涯中,有很多人帮助我成长和进步,但我的 Easy Metrics 团队特别值得表扬。如果没有你们多年来提供的知识和支持,我根本无法完成这些成就。Dean、Derek、Jessica、Josh、Owen 和 Paul:你们每个人都为团队带来了独特的技能,我从你们身上学到了很多东西。能把你们当作同事和朋友,我感到非常荣幸。Dan,你不仅是我的经理,更是我的朋友和师父。我在我们轻松的聊天中学到了比大多数人上学费还要多的商业知识。有了像我们这样的团队,什么都不可能做不到!Jay,除了感谢你为我做的一切,我还能说什么呢?从教我如何进行产品开发、鼓励我认真对待我的安全研究,到成为我疯狂想法的倾诉对象,帮助我适度地控制这些想法,你都是我伟大的导师和朋友,在我的篝火旁永远为你留一个位置。

在个人方面,有几位人士为我投入了大量的时间、精力、知识、爱和鼓励,我想让他们知道他们对我有多么重要。Tony 和 Bam,我的律师告诉我,我不能感谢你们在某些事件上给予的帮助,因为那可能涉及到当时某些事情的相关人,但你们知道的。你们不仅是我最亲密的朋友,你们是我在最需要的时候所需要的家庭。而且,和你们两个一起消磨时光,我学到的安全知识远超过任何课程、班级或书本所能教给我的。在很多方面,这一切都要怪你们!

最后,最大的感谢要给我的女朋友和孩子们,感谢你们的包容。我知道有时我可能让你们感到难以承受。你们曾经被迫听我即兴的数学讲座,或者在我去参加安全大会时被留在家里。每当我沉浸在数周的编程项目中时,都是你们照顾了所有的一切。你们为我的事业做出了所有可能的支持,我爱你们所有人。

前言

欢迎来到安全中的数学,这是一本据我估计,和我读过的任何其他安全书籍都不同的书。它并不是用来介绍诸如访问控制或加密等安全话题,也不打算帮助你准备下一个认证考试。然而,它将增强你检查周围世界并研究安全相关问题的能力。这本书的内容位于理论研究、实验和实践应用的交汇点上。我的目标是通过实际研究主题向你介绍关键的数学领域。我发现理解复杂理论原理最简单的方式,就是看到它在实际中的应用。

我并不是一个数学家,完全不敢妄称。我是一名安全实践者,热爱阅读理论研究论文,并且在多年的实践中发现,实际上很少有资源能够将这些理论研究转化为可测试的系统,也就是所谓的概念验证。我决定写一本当初我开始这条道路时希望拥有的书。在阅读这本书时,你将熟悉将数学理论转化为与安全相关的应用所需的工具和流程。通过审视在处理“野生”数据时不可避免的假设,你将学会如何准确评估和沟通工具和流程的局限性。最重要的是,我希望你能学会以一种全新、完全实用的视角来看待数学理论。

谁应该阅读这本书?

当我开始讨论安全中的应用数学时,常常会听到类似“但我不做加密”的话,这让我意识到许多安全从业者误解了数学在他们日常活动中的作用。事实上,应用数学是每个现代安全自动化工具的核心,不仅仅是加密工具。我希望任何对安全感兴趣并且有一定 Python 编程经验的人,都能从这些项目中找到有趣和有益的信息。如果你知道足够的 Python 来安装和导入包、读写文件以及管理基本的网络任务,那么你应该没问题。你不需要对数学有深刻的理解,因为我们会在使用公式时逐步解析它们。如果书名引起了你的注意,那么很可能你就是应该阅读这本书的人!

这本书包含了什么?

我将材料分为三部分。第一部分:环境和约定(第一章和第二章)将帮助您为编程设置 Python 环境,并介绍一些基本的符号概念。第二部分:图论和计算几何(第三章至第十章)介绍了我们将要讨论的数学的两个主要分支。最后,第三部分:艺术画廊问题(第十一章至第十三章)涵盖了一个大型项目,旨在解决经典的艺术画廊问题,该问题需要两个数学分支的知识来解答。每章的最后都有一个“总结”部分,介绍其他可能的应用、背景阅读材料和补充的数学主题,帮助您继续独立学习。以下是每章内容的简要概述:

第一章:设置环境 我们将从使用 Anaconda 或 pip 开始设置您的开发环境,具体选择取决于您的偏好以及对 Python 的熟悉程度。我们将讨论隔离开发环境的重要性,并介绍如何访问附加材料中包含的 Jupyter 笔记本。

第二章:编程和数学约定 在这一章中,我们将介绍您需要遵循的特殊编程语法和数学符号,以便跟随示例学习。我们将介绍 Python 的列表和字典推导式,以及zipunpack函数。然后,我们将从布尔代数和集合符号的复习开始,深入探讨数学方面的内容;我们还将研究数学世界中使用的各种符号。

第三章:利用图论保护网络 本章将介绍使用 NetworkX Python 库构建和分析图的概念。我们将讨论您可能遇到的一些基本类型的图,并分析一些关键统计数据,这些数据将帮助您更好地理解图的结构。

第四章:构建网络流量分析工具 在本项目中,我们将使用 NetworkX 和 Scapy 库构建一个图表,表示计算机网络中的通信。然后,我们将应用图的知识,揭示网络中计算机的一些有趣事实。最后,我们将通过一个概念验证项目,捕获网络数据包并使用它们创建您自己的图表。

第五章:利用社交网络分析识别威胁 本项目的重点是分析人际网络,而不是计算机网络。我们将使用模拟社交网络(如 Mastodon)数据构建一个图表。我们将讨论社交网络中连接形成的方式之一,并深入探讨一些实际的研究问题,以识别有趣的人物。最后,我们将通过一个概念验证项目,帮助您开始收集自己的数据,分析 Mastodon 时间线。

第六章:分析社交网络以防止安全事件 我们通过查看未来可能发生的情况,继续分析第五章中的社交网络。我们将使用随机性和概率创建一个模拟,预测消息如何在网络中传播,谁可能回应谁,以及讨论哪些话题。最后,我们将介绍我们的概念验证,一个双人对抗游戏,用以决定我们社交网络的命运。

第七章:利用几何学改进安全实践 在本章中,我们转向数学的第二个分支——计算几何学。我们将讨论如何使用 Shapely 库表示不同的形状,以及在处理形状数据时你将遇到的常见操作。本章还将介绍一些物理安全概念,如资源规划和位置,讨论如何使用几何学表示我们的计划。

第八章:利用数字信息跟踪物理空间中的人物 我们通过讨论计算几何学在定位物理世界中设备的应用,继续探索该领域。我们将介绍网络数据的结构,以及如何连接到 OpenCell API,收集关于网络的地理信息。我们还将讨论设备跟踪的伦理问题。本章的概念验证项目将输入一组塔数据,解决重叠的覆盖区域,并将其返回为一个有界的搜索区域。

第九章:计算几何学在安全资源分配中的应用 本章我们将讨论在进行资源分析时如何使用 Voronoi 图。我们将探讨俄勒冈州波特兰市消防站的当前分布情况,并分析新站点可以在哪个位置产生最大的影响。我们将介绍如何使用 OpenStreetMap API 获取更大区域的形状数据。在概念验证中,我们将创建一个应用程序,能够根据当前消防站的责任,程序化地推荐新消防站的选址。

第十章:面部识别中的计算几何学 在计算几何的最终项目中,我们将研究其在面部识别研究中的应用。我们将讨论什么样的图像才是好的,如何处理图像数据,如何通过形状来测量有趣的面部特征,以及如何找到最佳结果的关键特征。在这个特殊的两部分概念验证中,我们将开发一个系统,解决现代数据科学项目的两个方面:模型训练和模型应用。我们将制作一个能够处理图像集、训练面部分类器的系统,并最终将我们的方法应用于三张面孔,看看我们是否能仅通过计算几何正确识别它们。

第十一章:分发安全资源以保护空间 本章开始探讨艺术画廊问题以及一个更大项目的需求。我们将介绍支撑我们解决方案的理论和现有研究。接着,我们将讨论如何结合图论和计算几何学来改进基础方法,以提供更为实际的答案。本章中的代码将作为项目其余部分的基础,并涵盖生成我们感兴趣的实际解决方案。

第十二章:最小可行产品方法在安全软件开发中的应用 在这里,我们将把第十一章 中的项目从一个简单的概念验证扩展到更高级的功能,以改善用户体验。我们将讨论如何通过并行编程加速我们的应用程序。我们将简要介绍如何使用 PyGame 处理图形和用户交互。最后,我们将回顾本书附带的示例应用程序,并探索如何将其作为你自己艺术画廊问题解决方案的起点。

第十三章:交付 Python 应用程序 我们将在艺术画廊问题项目的最后,讨论现代软件交付方法。这是一个庞大的话题,因此我挑选了一些我认为每个人都应该熟悉的内容,从将你的应用程序打包为库到将其作为云服务交付。我们将讨论每种选项的优缺点以及它对你实现应用程序盈利能力的影响。

为什么选择 Python?

Python 满足了我们在本书中将要讨论的所有要求。Python 语言在安全社区有着悠久的成功历史,并且已经有大量使用它的工具和书籍。操作灵活性被认为是 Python 在安全社区广泛应用的主要原因之一。Python 在各种平台上表现良好,从微型单板计算机(如树莓派)到庞大的计算集群,以及所有介于两者之间的平台。使用 Python,你可以将自己的想法与大量现有的工作进行整合。

Python 还与应用数学界有着紧密的联系。在当今这个以计算机为中心的世界中,应用数学通常通过一种或多种高级编程语言来表达。在所有现代编程语言中,Python 因其易学性和几乎无限的表达能力,已成为数学和科学编程的领导者。Python 3 是我们进行探索性研究的自然选择,因为它有许多库和功能,可以帮助我们实现执行深入分析所需的各种算法。

这并不是说 Python 没有它的不足之处。Python 是一种解释型语言,这意味着一个程序(称为解释器)位于你编写的代码和执行该代码的系统之间。解释器的任务是将你的指令翻译成底层系统能理解的命令。

代码和系统之间有一个解释器,这就引入了一些问题。首先,指令和执行之间的额外层会增加处理时间和内存开销。其次,解释器本身是一个运行在你机器核心上的进程中的单一应用程序。然后,你的代码会在解释器进程的上下文中执行,这意味着你的整个应用程序受到操作系统分配给进程的极小系统资源的限制,即使你拥有一台配备足够内存的八核处理器,也无法突破这一限制。你可以采用一些编程技巧来规避这些限制(比如分布式处理,我们将在第十二章中讨论),但最终,Python 永远不会像 C 这样的编译语言那样快速和灵活。残酷的事实是,像 scikit-learn 和 NumPy 这样的数据科学库中,很多计算密集型函数都是在幕后调用编译后的 C 程序的包装器。

尽管存在一些不足,Python 仍然是我们目标实现的最佳选择。我将在第二章中介绍一些常见的惯用法,这些惯用法将在本书的其余部分中使用,并在接下来的章节中根据需要解释代码。

信息可访问性

在本书中,我将尽力以多种不同的方式呈现图表中的数据,例如将数字和符号与颜色渐变结合使用。我选择将形状或数字表示与颜色一起添加,是为了更清楚地传达要点,因为印刷版中的图像是灰度的。使用颜色之外的其他指示符也是为了考虑可访问性。不得不在一本书中仅使用灰度图像帮助我意识到,我们用颜色传达了多少信息,而这对那些无法区分大量颜色的人是不公平的。每当我们创建数据可视化时,我们应该努力提供多种方式让人们区分关键点,而不是过度依赖漂亮的颜色渐变来代替我们的表达。如果你在理解这些信息时有可访问性方面的需求或疑虑,请通过本书的 GitHub 页面(github.com/dreilly369/AppliedMathForSecurityBook)与我联系,我会尽力提供解决方案。

在线资源

本书的补充材料和随书提供的 Jupyter Notebooks 可以在github.com/dreilly369/AppliedMathForSecurityBook获取。

第一部分

环境与约定

第二部分

图论与计算几何

第三部分

艺术画廊问题

第二十三章:详细目录

  1. 封面页

  2. 版权

  3. 献词

  4. 关于作者

  5. 致谢

  6. 介绍

    1. 谁应该阅读本书?

    2. 本书内容

    3. 为什么选择 Python?

    4. 信息可访问性

    5. 在线资源

  7. 第一部分:环境和约定

    1. 第一章:设置环境

      1. 使用 Anaconda 简单配置环境

        1. Linux

        2. Windows

        3. macOS

      2. 设置虚拟环境

      3. 使用 Anaconda 安装 IDE

      4. 高级设置

        1. 设置虚拟环境

        2. 不使用 Anaconda 安装 IDE

      5. Jupyter Notebooks

      6. 总结

    2. 第二章:编程和数学约定

      1. 语法结构

        1. 列表推导式

        2. 字典推导式

        3. 压缩和解压

      2. 数学符号

        1. 布尔符号

        2. 集合符号

        3. 属性字符

        4. 希腊字母和函数

      3. 总结

  8. 第二部分:图论与计算几何

    1. 第三章:通过图论保护网络

      1. 图论在安全应用中的作用

      2. 在 NetworkX 中创建图

      3. 在数据中发现关系

        1. 衡量节点重要性

        2. 分析团体以追踪关联

        3. 确定网络的连通性

        4. 使用图的边捕捉重要细节

      4. 总结

    2. 第四章:构建网络流量分析工具

      1. 网络拓扑可视化

      2. 将网络信息转换为图

        1. 构建通信图

        2. 构建图

      3. 识别可疑的机器行为

        1. 端口数据量子图

        2. 识别异常流量水平

        3. 检查机器如何在网络中互动

      4. 概念验证:网络流量分析

      5. 总结

    3. 第五章:利用社交网络分析识别威胁

      1. 小世界现象

      2. 图形化社交网络数据

        1. 数据结构化

        2. 可视化社交网络

      3. 网络分析洞察

        1. 计算信息传播

        2. 识别团体和最具影响力的用户

        3. 找到受影响最大的用户

        4. 使用基于话题的信息交换

        5. 分析网络组织

      4. 概念验证:社交网络分析

      5. 社交网络分析的阴暗面

      6. 总结

    4. 第六章:分析社交网络以防止安全事件

      1. 使用蒙特卡洛模拟预测攻击

        1. 有限状态机

        2. 网络建模与随机游走

        3. 蒙特卡洛模拟

      2. 模拟社交网络

        1. 建模用户互动

        2. 建模基于话题的影响

        3. 建模信息流

      3. 概念验证:打乱信息流

        1. 建模演化中的网络

        2. 在网络中传播信息

        3. 测量信息流量

        4. 游戏如何运作

        5. 游戏目标

        6. 游戏模拟

        7. 对玩家 2 的改进

      4. 总结

    5. 第七章:利用几何学改进安全实践

      1. 描述形状

        1. 点与线

        2. 多边形

        3. 顶点顺序

      2. 场景:为音乐会规划安全

        1. 计算安全容纳限制

        2. 确定安保人员的部署位置

        3. 估算警卫巡逻时间

        4. 改善警卫部署

      3. 总结

    6. 第八章:使用数字信息在物理空间中追踪人员

      1. 收集蜂窝网络数据

      2. 跟踪设备和人员的伦理问题

      3. OpenCellID API 结构

      4. 概念验证:通过附近的基站定位设备

        1. 收集塔的位置

        2. 将地理坐标转换为多边形

        3. 计算搜索区域

        4. 为调查员绘制搜索区域

        5. 减少搜索区域

      5. 总结

    7. 第九章:安全资源分配的计算几何

      1. 使用 Voronoi 剖分进行资源分配

      2. 概念验证:分析消防站覆盖范围

        1. 定义距离函数

        2. 确定城市形状

        3. 收集现有消防站的位置

        4. 执行 Voronoi 分析

      3. 算法的局限性

      4. 总结

    8. 第十章:面部识别的计算几何

      1. 面部识别在安全中的应用

      2. 面部识别研究的伦理

      3. 面部识别算法

        1. 使用决策树分类器

        2. 表示面部几何

        3. 处理图像数据

        4. 定位面部标志

      4. 概念验证:开发面部识别系统

        1. 面部统计

        2. 内存管理

        3. 数据加载

        4. 特征工程

        5. 模型训练

        6. 模型持久性

      5. 总结

  9. 第三部分:艺术画廊问题

    1. 第十一章:分配安全资源以守护空间

      1. 确定最少守卫人数

      2. 艺术画廊问题理论

      3. 画廊的几何和图形表示

      4. 保护画廊

        1. 绘制守卫覆盖区域

        2. 定义受阻区域

        3. 优先考虑守卫覆盖区域

        4. 绘制安全摄像头视场

      5. 总结

    2. 第十二章:最小可行产品方法在安全软件开发中的应用

      1. 绘制用户交互图

        1. 规划应用状态

        2. 记录应用程序

      2. 开发状态管理器

      3. 通过并行处理加速安全性

        1. 线程并行性

        2. 处理器并行性

      4. 添加图形用户界面

        1. 在 PyGame 中显示和管理图像

        2. 使用精灵和层组织图形

      5. 保存和重新加载项目数据

        1. 保存到字典

        2. 从 JSON 文件加载

      6. 运行示例应用

      7. 总结

    3. 第十三章:交付 Python 应用

      1. 使用安装脚本

      2. 使用 Python 解释器打包

      3. 使用云微服务进行分发

      4. 使用 PyArmor 授权

      5. 开源交付

      6. 总结

  10. 笔记

  11. 索引

表格列表

  1. 表 2-1:布尔逻辑示例

  2. 表 2-2:集合表示法示例

  3. 表 2-3:保留集合

  4. 表 2-4:函数表示法示例

插图列表

  1. 图 1-1:Windows 上的 Anaconda 安装程序

  2. 图 1-2:Anaconda Navigator 界面

  3. 图 1-3:Anaconda 安装屏幕

  4. 图 2-1:使用θ表示角度的勾股定理

  5. 图 3-1:旅行图

  6. 图 3-2:图论的情报收集应用

  7. 图 3-3:一个状态机图

  8. 图 3-4:一个无向图

  9. 图 3-5:一个简单的代理网络

  10. 图 3-6:卡通角色图

  11. 图 3-7:一个不连通的图

  12. 图 3-8:比较单边和多边图

  13. 图 4-1:Zenmap 的示例网络拓扑视图

  14. 图 4-2:网络的 3D 表示

  15. 图 4-3:网络上 HTTP 流量的协议子图

  16. 图 4-4:具有最多外部连接的节点图

  17. 图 5-1:社交网络图的 3D 可视化

  18. 图 5-2:用户回应最多的子图

  19. 图 5-3:找到影响力最大的用户

  20. 图 5-4:环境与政治的主题子图示例

  21. 图 5-5:组织结构图的示例树形结构

  22. 图 5-6:一般的祖先图示

  23. 图 6-1:一个简单的有限状态机

  24. 图 6-2:二维和三维随机行走示例

  25. 图 6-3:随机行走蒙特卡罗模拟

  26. 图 6-4:比较概率分布

  27. 图 7-1:凹形与凸形

  28. 图 7-2:比较多边形类型

  29. 图 7-3:带孔的公园作为多边形

  30. 图 7-4:简单多边形和带孔多边形的三角形镶嵌

  31. 图 7-5:安全人员的质心布置

  32. 图 7-6:改进的安全布置结果

  33. 图 8-1:派克市场区域的网络数据

  34. 图 8-2:显示塔的范围

  35. 图 8-3:塔信号重叠

  36. 图 8-4:四个塔的交集作为多边形

  37. 图 8-5:结果搜索区域

  38. 图 8-6:比较位置估计

  39. 图 9-1:随机生成的 Voronoi 镶嵌

  40. 图 9-2:波特兰市作为多边形

  41. 图 9-3:消防站位置作为点

  42. 图 9-4:Voronoi 镶嵌,显示每个站点的责任区域

  43. 图 9-5:最大区域及其生成器位置

  44. 图 10-1:高尔夫决策树示例

  45. 图 10-2:面部兴趣点,由算法生成(图片来源:https://i.stack.imgur.com/OBgDf.png)

  46. 图 10-3:处理过的面部图像,准备分析

  47. 图 10-4:地标检测结果

  48. 图 10-5:自动面部镶嵌的结果

  49. 图 10-6:特征关联矩阵

  50. 图 11-1:解决最简单的艺术画廊问题

  51. 图 11-2:由弗兰克·劳埃德·赖特设计的古根海姆博物馆(© 2023 弗兰克·劳埃德·赖特基金会。版权所有。由艺术家权利协会授权。)

  52. 图 11-3:用多边形和图表示画廊

  53. 图 11-4:对示例画廊进行三角剖分的结果

  54. 图 11-5:对示例画廊进行贪心着色的结果

  55. 图 11-6:最大区域面积 30 平方米解决方案

  56. 图 11-7:从选定的部署创建 AOR 图

  57. 图 11-8:将 AGP 算法应用于复杂多边形

  58. 图 11-9:添加区域分配后的画廊

  59. 图 11-10:创建多区域网格划分

  60. 图 11-11:精细化网格,带有三个保护层

  61. 图 11-12:定义视野多边形

  62. 图 12-1:多个会话工作流图

  63. 图 12-2:使用 PyGame 在屏幕上绘制图形

  64. 图 12-3:使用精灵绘制多边形

  65. 图 13-1:微服务架构图

列表总览

  1. 列表 3-1:创建基础加权无向图

  2. 列表 3-2:列表 3-1 创建的图的介数中心性

  3. 列表 3-3:度中心性,列表 3-2 的变化部分用粗体标出

  4. 列表 3-4:创建有向图以衡量入度和出度中心性

  5. 列表 3-5:创建图 3-6 中的漫画社群图

  6. 列表 3-6:从有向图创建社群列表

  7. 列表 4-1:从 pcap 文件填充图

  8. 列表 4-2:协议子图辅助函数

  9. 列表 4-3:查找具有最多出站流量的机器(单一协议)

  10. 列表 4-4:为多个协议定位具有最高出站流量的机器

  11. 列表 4-5:使用 z-score 标准化识别异常值

  12. 列表 4-6:查找具有最多出站连接的机器

  13. 列表 4-7:计算所有 IER 的函数

  14. 列表 4-8:查找信息吸收比率最高的节点

  15. 列表 4-9:概念验证运行选项

  16. 列表 5-1:使用 Mastodon 架构示例的模拟 API 响应

  17. 列表 5-2:从示例 JSON 数据创建 pandas DataFrame 对象

  18. 列表 5-3:Pandas 中的帖子数据结构

  19. 列表 5-4:将帖子数据表示为有向图

  20. 列表 5-5:对示例数据应用 RI 算法

  21. 列表 5-6:转换为无向图以查找社群

  22. 列表 5-7:查找具有最高出度节点的所有最大社群

  23. 列表 5-8:构建基于主题的子图并运行 HITS 算法

  24. 列表 5-9:统计所有节点的 LCA 出现次数

  25. 列表 5-10:LCA 分析结果

  26. 列表 5-11:捕获 Mastodon 公共时间线数据到 CSV 文件

  27. 列表 6-1:蒙特卡洛模拟的初始化代码

  28. 列表 6-2:确定性消息传递蒙特卡洛模拟

  29. 列表 6-3: 基于主题的消息传递蒙特卡罗模拟

  30. 列表 6-4: 平均蒙特卡罗模拟结果

  31. 列表 6-5: 基于术语定义子图

  32. 列表 6-6: 检查终止条件

  33. 列表 6-7: 用于偏差行走的加权随机选择函数

  34. 列表 6-8: 没有消息的节点的加权输入

  35. 列表 6-9: 定向优先附着加权随机选择

  36. 列表 6-10: 节点的容量加权

  37. 列表 6-11: 加权随机断开函数

  38. 列表 6-12: 定义玩家 1 回合的逻辑

  39. 列表 6-13: 玩家 2 的随机实现

  40. 列表 6-14: 为加权选择创建平均长度得分

  41. 列表 6-15: 主游戏模拟函数

  42. 列表 6-16: 用更多智能更新玩家 2

  43. 列表 7-1: 在 Shapely 中定义一个点

  44. 列表 7-2: 从 Point 对象创建 LineString

  45. 列表 7-3: 从 Point 对象创建 Polygon

  46. 列表 7-4: 在 Shapely 中创建带孔的多边形

  47. 列表 7-5: 检查并修正顶点顺序

  48. 列表 7-6: 创建复杂的公园形状并计算面积

  49. 列表 7-7: 根据可用面积计算容量

  50. 列表 7-8: 基于质心的人员分散算法

  51. 列表 7-9: 计算警卫巡逻时间

  52. 列表 7-10: 从不可用区域重新分配警卫站

  53. 列表 8-1: API 负载结构

  54. 列表 8-2: 调用 API 并解码响应的函数

  55. 列表 8-3: 单塔 JSON 查询响应

  56. 列表 8-4: 收集塔的位置地理信息

  57. 列表 8-5: 从塔位置创建 GeoDataFrame

  58. 列表 8-6: 将地理点转换为地理多边形

  59. 列表 8-7: 将多边形分割成差集和交集元素

  60. 列表 8-8: 用于多边形交集级联的扫描线算法

  61. 列表 8-9: 删除小面积多边形

  62. 列表 9-1: 通过 Nominatim 获取波特兰市的 JSON 数据

  63. 列表 9-2: 将坐标转换为 geoJSON 特性集合

  64. 列表 9-3: 将城市几何转换为 Shapely 形状集合

  65. 清单 9-4: 快速简便地将数据加载到DataFrame

  66. 清单 9-5: 将地址转换为地理坐标点

  67. 清单 9-6: 从站点位置创建DataFrame

  68. 清单 9-7: 将地址转换为地理坐标点

  69. 清单 9-8: 查找最大 AOR

  70. 清单 10-1: 加载面部标志检测器

  71. 清单 10-2: 处理单个.jpeg图像的函数

  72. 清单 10-3: 在图像中定位特征标志

  73. 清单 10-4: 使用 Shapely 定义几何统计数据

  74. 清单 10-5: 遍历图像文件进行处理

  75. 清单 10-6: 准备facial_geometry.csv数据进行训练

  76. 清单 10-7: 随机选择一个真实的保留数据集

  77. 清单 10-8: 计算特征集的关联矩阵

  78. 清单 10-9: 计算每个特征的 MI 贡献

  79. 清单 10-10: 查找最佳特征列表之间的重叠部分

  80. 清单 10-11: 计算特征的相关比率

  81. 清单 10-12: 创建训练集和测试集的拆分

  82. 清单 10-13: 训练基准虚拟分类器

  83. 清单 10-14: 决策树算法定义

  84. 清单 10-15: 使用真实保留数据集进行测试

  85. 清单 11-1: 创建画廊表示

  86. 清单 11-2: 使用 Triangle 库执行三角形镶嵌

  87. 清单 11-3: 更新画廊表示

  88. 清单 11-4: 将三角形分配给防护节点

  89. 清单 11-5: 汇总 AOR 区域

  90. 清单 11-6: 对复杂多边形进行镶嵌

  91. 清单 11-7: 添加用于镶嵌的区域定义

  92. 清单 11-8: 将镶嵌结果保存到 Triangle 项目文件中

  93. 清单 12-1: 处理 PyGame 事件

  94. 清单 12-2: 在状态管理器中处理KEYDOWN事件

  95. 清单 12-3: 在状态管理器中处理KEYUP事件

  96. 清单 12-4: 在状态管理器中处理MOUSEBUTTONDOWN事件

  97. 清单 12-5: 使用线程并行处理并发图像显示

  98. 清单 12-6: 使用DisplayAGP类并发显示图像

  99. 清单 12-7: 使用多进程并发求解楼层问题

  100. 清单 12-8: 修改求解器以便在多进程中使用

  101. 清单 12-9: 使用 PyGame 显示模块显示图形

  102. 清单 12-10: 为 Room 多边形创建自定义精灵类

  103. 清单 12-11: 使用 Room 精灵显示多边形

  104. 清单 12-12: 为 Room 类创建字典表示

  105. 清单 12-13: 保存对象的 JSON 表示

  106. 清单 12-14: 从已创建的 ZIP 文件中提取文件

  107. 清单 12-15: 从字典重新加载 Room

指南

  1. 封面

  2. 前言

  3. 致谢

  4. 简介

  5. 开始阅读

  6. 索引

  1. i

  2. ii

  3. iii

  4. v

  5. xv

  6. xvi

  7. xvii

  8. xviii

  9. xix

  10. xx

  11. xxi

  12. xxii

  13. 1

  14. 3

  15. 4

  16. 5

  17. 6

  18. 7

  19. 8

  20. 9

  21. 10

  22. 11

  23. 12

  24. 13

  25. 14

  26. 15

  27. 16

  28. 17

  29. 18

  30. 19

  31. 20

  32. 21

  33. 22

  34. 23

  35. 25

  36. 27

  37. 28

  38. 29

  39. 30

  40. 31

  41. 32

  42. 33

  43. 34

  44. 35

  45. 36

  46. 37

  47. 38

  48. 39

  49. 40

  50. 41

  51. 42

  52. 43

  53. 44

  54. 45

  55. 46

  56. 47

  57. 48

  58. 49

  59. 50

  60. 51

  61. 52

  62. 53

  63. 54

  64. 55

  65. 56

  66. 57

  67. 58

  68. 59

  69. 60

  70. 61

  71. 62

  72. 63

  73. 64

  74. 65

  75. 67

  76. 68

  77. 69

  78. 70

  79. 71

  80. 72

  81. 73

  82. 74

  83. 75

  84. 76

  85. 77

  86. 78

  87. 79

  88. 80

  89. 81

  90. 82

  91. 83

  92. 84

  93. 85

  94. 86

  95. 87

  96. 88

  97. 89

  98. 90

  99. 91

  100. 92

  101. 93

  102. 94

  103. 95

  104. 96

  105. 97

  106. 98

  107. 99

  108. 100

  109. 101

  110. 102

  111. 103

  112. 104

  113. 105

  114. 106

  115. 107

  116. 108

  117. 109

  118. 110

  119. 111

  120. 112

  121. 113

  122. 114

  123. 115

  124. 116

  125. 117

  126. 118

  127. 119

  128. 120

  129. 121

  130. 122

  131. 123

  132. 124

  133. 125

  134. 127

  135. 128

  136. 129

  137. 130

  138. 131

  139. 132

  140. 133

  141. 134

  142. 135

  143. 136

  144. 137

  145. 138

  146. 139

  147. 140

  148. 141

  149. 142

  150. 143

  151. 144

  152. 145

  153. 146

  154. 147

  155. 148

  156. 149

  157. 150

  158. 151

  159. 152

  160. 153

  161. 154

  162. 155

  163. 156

  164. 157

  165. 158

  166. 159

  167. 160

  168. 161

  169. 162

  170. 163

  171. 164

  172. 165

  173. 166

  174. 167

  175. 168

  176. 169

  177. 170

  178. 171

  179. 172

  180. 173

  181. 174

  182. 175

  183. 176

  184. 177

  185. 178

  186. 179

  187. 180

  188. 181

  189. 182

  190. 183

  191. 184

  192. 185

  193. 186

  194. 187

  195. 188

  196. 189

  197. 190

  198. 191

  199. 192

  200. 193

  201. 194

  202. 195

  203. 196

  204. 197

  205. 198

  206. 199

  207. 200

  208. 201

  209. 202

  210. 203

  211. 204

  212. 205

  213. 206

  214. 207

  215. 209

  216. 210

  217. 211

  218. 212

  219. 213

  220. 214

  221. 215

  222. 216

  223. 217

  224. 218

  225. 219

  226. 220

  227. 221

  228. 222

  229. 223

  230. 224

  231. 225

  232. 226

  233. 227

  234. 228

  235. 229

  236. 230

  237. 231

  238. 232

  239. 233

  240. 234

  241. 235

  242. 236

  243. 237

  244. 238

  245. 239

  246. 240

  247. 241

  248. 242

  249. 243

  250. 244

  251. 245

  252. 246

  253. 247

  254. 248

  255. 249

  256. 250

  257. 251

  258. 252

  259. 253

  260. 254

  261. 255

  262. 256

  263. 257

  264. 258

  265. 259

  266. 260

  267. 261

  268. 262

  269. 263

  270. 264

  271. 265

  272. 266

  273. 267

  274. 269

  275. 270

  276. 271

  277. 272

  278. 273

  279. 275

  280. 276

  281. 277

  282. 278

  283. 279

  284. 280

  285. 281

  286. 282

  287. 283

  288. 284

  289. 285

  290. 286

  291. 287

  292. 288

posted @ 2025-11-30 19:37  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报