0%

做推荐系统时,一个很常见的问题是:模型到底该怎么评估?

如果只是看 loss,往往是不够的。因为推荐系统真正关心的通常不是“概率有没有拟合好”,而是:

  • 正样本能不能被排到前面
  • 用户真正感兴趣的 item 能不能被召回
  • 前几个推荐位里有没有足够多的好结果

所以推荐系统的评估指标,很多时候本质上是在回答两个问题:

  1. 模型能不能把正样本排在负样本前面
  2. 模型能不能把用户真正想看的内容放到 Top-K

下面是推荐系统里常见的几类指标。

Precision, Recall

Precision 和 Recall 是最基础的一组指标。它们最常出现在召回阶段或者 Top-K 推荐评估里。

先约定几个量:

  • TP:真正例,推荐了且用户确实喜欢
  • FP:假正例,推荐了但用户不喜欢
  • FN:假负例,没有推荐但用户其实喜欢

Precision

Precision 关注的是:

  • 你推荐出去的东西里,有多少是真的对的

公式为:

$$
\mathrm{Precision} = \frac{TP}{TP + FP}
$$

例如给一个用户推荐了 10 个商品,其中 4 个是他真正点击或购买的,那么:

$$
\mathrm{Precision} = \frac{4}{10} = 0.4
$$

在推荐系统里,Precision 高说明:

  • 推荐结果更“准”
  • 暴露出去的位置浪费更少

但它也有局限,因为它不关心你漏掉了多少用户真正喜欢的内容。

Recall

Recall 关注的是:

  • 用户真正感兴趣的东西里,你找回来了多少

公式为:

$$
\mathrm{Recall} = \frac{TP}{TP + FN}
$$

例如某个用户一共会喜欢 20 个 item,但你的系统只召回了其中 6 个,那么:

$$
\mathrm{Recall} = \frac{6}{20} = 0.3
$$

在推荐系统里,Recall 尤其适合评估召回层,因为召回阶段的目标本来就不是特别精准,而是先尽量不要漏掉好东西。

Precision@K 和 Recall@K

在实际推荐里,我们通常更关心前 $K$ 个结果,所以常写成:

$$
\mathrm{Precision@K} = \frac{\mathrm{Hits@K}}{K}
$$

$$
\mathrm{Recall@K} = \frac{\mathrm{Hits@K}}{\mathrm{Relevant}}
$$

例如用户真实喜欢的 item 有 5 个,模型给出的 Top-10 里命中了 3 个,那么:

$$
\mathrm{Precision@10} = \frac{3}{10}
$$

$$
\mathrm{Recall@10} = \frac{3}{5}
$$

可以看到:

  • Precision@K 更关心“前 K 个准不准”
  • Recall@K 更关心“前 K 个够不够全”

AUC, F1

这一组指标更常出现在 CTR 预估、点击预测、二分类推荐任务里。

AUC

AUC 的全称是 Area Under ROC Curve。它衡量的是:

  • 随机抽一个正样本和一个负样本
  • 模型把正样本排在负样本前面的概率有多大

如果记正样本得分为 $s^+$,负样本得分为 $s^-$,那么 AUC 可以理解为:

$$
\mathrm{AUC} = P(s^+ > s^-)
$$

这个解释非常适合推荐系统,因为很多推荐任务本质上就是排序任务。

例如:

  • 点击样本应该比未点击样本得分更高
  • 购买样本应该比曝光未购买样本得分更高

AUC 的优点是:

  • 不依赖具体阈值
  • 更关注整体排序能力
  • 对类别不平衡通常比 Accuracy 更稳

所以在 CTR 预估里,AUC 往往比 Accuracy 更常见。

不过 AUC 也有明显局限:

  • 它衡量的是整体排序
  • 但不特别关注 Top-K 位置

也就是说,一个模型 AUC 很高,不代表它前几个推荐位一定最好。

F1

F1 是 Precision 和 Recall 的调和平均,用来平衡“查得准”和“查得全”。

公式为:

$$
F1 = \frac{2 \cdot \mathrm{Precision} \cdot \mathrm{Recall}}{\mathrm{Precision} + \mathrm{Recall}}
$$

如果 Precision 很高但 Recall 很低,或者反过来,F1 都不会太高。

例如:

$$
\mathrm{Precision} = 0.8,\quad \mathrm{Recall} = 0.4
$$

那么:

$$
F1 = \frac{2 \times 0.8 \times 0.4}{0.8 + 0.4} = 0.533
$$

F1 的意义是:

  • 不希望模型只顾着精准,结果漏掉大量正样本
  • 也不希望模型只顾着召回,结果引入大量噪声

不过在推荐系统里,F1 没有 AUC、NDCG 那么常用。因为推荐更常见的是排序问题,而 F1 更像是一个固定阈值下的分类指标。

NDCG

NDCG 是推荐系统和搜索排序里非常常见的排序指标。它特别适合回答一个问题:

  • 相关 item 不只是“有没有排进去”
  • 而是“有没有排在足够靠前的位置”

DCG

DCG 的全称是 Discounted Cumulative Gain。它的思想是:

  • 排名越靠前,贡献越大
  • 排名越靠后,价值要打折

如果第 $i$ 个位置的相关性记作 $\mathrm{rel}_i$,那么 DCG 可以写成:

$$
\mathrm{DCG@K} = \sum_{i=1}^{K} \frac{2^{\mathrm{rel}_i} - 1}{\log_2(i+1)}
$$

这里的分母 $\log_2(i+1)$ 就是在做位置折损。

如果相关性是二值的,也就是:

  • 相关记作 1
  • 不相关记作 0

那么公式会退化成更简单的形式。

IDCG

不同用户的相关 item 数量可能不同,所以单独看 DCG 不方便横向比较。于是需要一个“理想排序”下的 DCG,记作 IDCG:

$$
\mathrm{IDCG@K} = \sum_{i=1}^{K} \frac{2^{\mathrm{rel}_i^*} - 1}{\log_2(i+1)}
$$

其中 $\mathrm{rel}_i^*$ 表示理想排序下第 $i$ 个位置的相关性。

NDCG

NDCG 就是把 DCG 归一化:

$$
\mathrm{NDCG@K} = \frac{\mathrm{DCG@K}}{\mathrm{IDCG@K}}
$$

这样它的取值通常在 $[0,1]$ 之间,越接近 1 越好。

NDCG 的优点非常适合推荐排序:

  • 它关注位置,前面的错排惩罚更大
  • 它允许相关性有等级,不一定只能是 0/1
  • 它适合评估 Top-K 排序质量

例如:

  • 点击、加购、购买可以赋予不同相关性等级
  • 同样命中 3 个正样本,排在第 1、2、3 位显然比排在第 8、9、10 位更好

这正是 NDCG 想表达的东西。

MRR

MRR 的全称是 Mean Reciprocal Rank。它特别关注:

  • 第一个正确结果出现得有多靠前

先定义某个用户的第一个相关结果的排名为 $\mathrm{rank}_u$,那么该用户的倒数排名为:

$$
\mathrm{RR}_u = \frac{1}{\mathrm{rank}_u}
$$

对所有用户取平均,就得到 MRR:

$$
\mathrm{MRR} = \frac{1}{|U|}\sum_{u \in U}\frac{1}{\mathrm{rank}_u}
$$

例如:

  • 如果第一个正确结果排第 1 位,那么 RR = 1
  • 如果排第 2 位,那么 RR = 0.5
  • 如果排第 10 位,那么 RR = 0.1

MRR 很适合下面这类场景:

  • 用户通常只会点第一个最相关结果
  • 系统更关心“第一个命中出现得够不够早”

比如:

  • 问答系统
  • 搜索建议
  • 单目标检索

但在推荐系统里,如果我们关心的是“前 20 个里整体好不好”,那 NDCG 往往比 MRR 更合适。因为 MRR 只看第一个相关结果,后面的命中不会继续加分。

不同指标怎么选

推荐系统里没有哪个指标是“万能”的,关键看你评估的是哪一层。

1. 召回阶段

更常看:

  • Recall@K
  • Precision@K

因为召回层的重点是:

  • 不要漏掉太多好 item
  • 在有限候选数里尽量保留有效结果

2. 排序阶段

更常看:

  • NDCG@K
  • MRR
  • AUC

其中:

  • NDCG@K 适合看整体 Top-K 排序质量
  • MRR 适合看第一个命中位置
  • AUC 适合看整体 pairwise 排序能力

3. CTR 预估或二分类任务

更常看:

  • AUC
  • LogLoss
  • F1

其中 AUC 最常见,因为它不依赖阈值,而且在样本极不平衡时通常更稳。

一个直观总结

可以把这些指标粗略理解成下面几类:

  • Precision:推荐出去的东西有多准
  • Recall:用户喜欢的东西找回来了多少
  • F1:Precision 和 Recall 的平衡
  • AUC:整体排序能力强不强
  • NDCG:前排位置排得好不好
  • MRR:第一个正确结果出现得早不早

如果只记一句话,那么可以记成:

  • 召回看 Recall@K
  • 排序看 NDCG@K
  • CTR 看 AUC

当然,真实系统里通常不会只看一个指标,而是会把多个指标放在一起看。因为一个模型可能:

  • AUC 更高,但 Top-K 更差
  • Recall 更高,但 Precision 明显下降
  • NDCG 更高,但覆盖率变差

所以评估的关键,不只是“哪个指标高”,而是:

  • 这个指标是否真的对应你当前业务阶段的目标

Elasticsearch 最强大的地方,不只是它能“存数据”和“搜数据”,而是它提供了一套非常灵活的查询语言,也就是 DSL。

很多人第一次接触 Elasticsearch 时,会觉得它和 SQL 很不一样。SQL 更像是面向“表”的查询,而 Elasticsearch DSL 更像是面向“文档”和“倒排索引”的检索描述语言。

这篇文章想讲清楚几个最常用的问题:

  • 什么是 Elasticsearch DSL
  • matchtermbool 分别怎么用
  • 查询和过滤有什么区别
  • 排序、分页、聚合应该怎么写
  • 平时最容易踩哪些坑

一、什么是 DSL

DSL 全称是 Domain Specific Language,也就是“领域特定语言”。

在 Elasticsearch 中,DSL 本质上就是一段 JSON,用来描述:

  • 我要查哪些文档
  • 条件是什么
  • 怎么排序
  • 怎么分页
  • 是否要做聚合统计

例如最简单的一条查询:

1
2
3
4
5
6
7
8
GET /product/_search
{
"query": {
"match": {
"title": "iphone"
}
}
}

这段 DSL 的含义就是:

  • product 索引中搜索
  • 查找 title 字段中和 iphone 相关的文档

所以可以把 DSL 理解成 Elasticsearch 的查询描述语言。

二、一个完整搜索请求通常包含什么

一个典型的 Elasticsearch 搜索请求,通常由下面几部分组成:

  • query:查询条件
  • sort:排序规则
  • from / size:分页
  • _source:返回哪些字段
  • aggs:聚合分析

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
GET /product/_search
{
"_source": ["title", "price", "brand"],
"query": {
"bool": {
"must": [
{ "match": { "title": "iphone" } }
],
"filter": [
{ "term": { "brand.keyword": "Apple" } },
{ "range": { "price": { "gte": 5000, "lte": 10000 } } }
]
}
},
"sort": [
{ "price": "asc" }
],
"from": 0,
"size": 10
}

这一条请求已经很接近真实业务了:

  • 关键词搜索 iphone
  • 只看 Apple 品牌
  • 只要 5000 到 10000 的商品
  • 按价格升序排序
  • 返回前 10 条

三、Query 和 Filter 的区别

这是 Elasticsearch DSL 最值得先理解的地方。

虽然它们看起来都在“筛选文档”,但语义完全不同。

1. Query

query 更强调“相关性”。

它通常会计算 _score,也就是相关性得分。例如:

  • 关键词是否命中
  • 命中了几个词
  • 词频如何
  • 文档是否更接近查询意图

所以 query 适合全文检索场景,例如:

  • 搜文章标题
  • 搜商品名称
  • 搜评论内容

2. Filter

filter 更强调“条件过滤”。

它只关心:

  • 满足
  • 或不满足

而不会参与相关性评分,通常也更高效,很多情况下还能利用缓存。

所以 filter 更适合结构化条件,例如:

  • 品牌是否等于 Apple
  • 价格是否大于 5000
  • 状态是否为已上架
  • 时间是否在某个区间内

一句话总结:

  • 需要相关性计算,用 query
  • 只是精确筛选,用 filter

四、最常用的查询类型

1. match

match 是最常见的全文检索查询。

1
2
3
4
5
6
7
8
GET /article/_search
{
"query": {
"match": {
"title": "深度学习推荐系统"
}
}
}

它会对查询词进行分词,再基于倒排索引检索。

适合字段:

  • text

不适合字段:

  • keyword
  • 数值字段
  • 日期字段

2. term

term 用于精确匹配,不做分词。

1
2
3
4
5
6
7
8
GET /product/_search
{
"query": {
"term": {
"brand.keyword": "Apple"
}
}
}

适合场景:

  • ID
  • 枚举值
  • 状态字段
  • keyword 类型字段

这是最常见的坑之一:

  • text 字段做全文搜索,不该用 term
  • 对精确值做过滤,通常不该用 match

3. terms

terms 相当于 SQL 里的 IN

1
2
3
4
5
6
7
8
GET /product/_search
{
"query": {
"terms": {
"category_id": [1, 2, 3]
}
}
}

4. range

range 用于范围查询。

1
2
3
4
5
6
7
8
9
10
11
GET /product/_search
{
"query": {
"range": {
"price": {
"gte": 5000,
"lte": 10000
}
}
}
}

常见关键字:

  • gt:大于
  • gte:大于等于
  • lt:小于
  • lte:小于等于

5. match_phrase

match_phrase 用于短语匹配,强调词序。

1
2
3
4
5
6
7
8
GET /article/_search
{
"query": {
"match_phrase": {
"title": "推荐系统 架构"
}
}
}

适合需要“连续短语命中”的场景。

五、bool 查询

实际业务中,查询条件往往不会只有一个。Elasticsearch 里最常用的组合方式就是 bool

bool 最常见的四个子句是:

  • must
  • should
  • filter
  • must_not

1. must

必须满足,并参与评分。

1
2
3
"must": [
{ "match": { "title": "iphone" } }
]

2. filter

必须满足,但不参与评分。

1
2
3
"filter": [
{ "term": { "brand.keyword": "Apple" } }
]

3. should

最好满足,相当于加分项。

1
2
3
4
"should": [
{ "match": { "title": "pro" } },
{ "match": { "title": "max" } }
]

4. must_not

必须不满足。

1
2
3
"must_not": [
{ "term": { "status.keyword": "offline" } }
]

5. 一个完整示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
GET /product/_search
{
"query": {
"bool": {
"must": [
{ "match": { "title": "iphone" } }
],
"filter": [
{ "term": { "brand.keyword": "Apple" } },
{ "range": { "price": { "gte": 5000 } } }
],
"must_not": [
{ "term": { "status.keyword": "offline" } }
],
"should": [
{ "match": { "title": "pro" } }
]
}
}
}

六、排序和分页

1. 排序

1
2
3
4
5
6
7
8
9
10
11
12
GET /product/_search
{
"query": {
"match": {
"title": "iphone"
}
},
"sort": [
{ "_score": "desc" },
{ "price": "asc" }
]
}

常见排序字段:

  • _score
  • 数值字段
  • 日期字段
  • keyword 字段

如果对 text 字段排序,通常会有问题,一般要排序对应的 .keyword 子字段。

2. 分页

最常见的分页方式是:

1
2
3
4
5
6
7
8
GET /product/_search
{
"from": 0,
"size": 10,
"query": {
"match_all": {}
}
}

含义是:

  • 从第 0 条开始
  • 返回 10 条

但需要注意,from + size 很大时,深分页性能会明显变差。

如果是深分页场景,通常更推荐:

  • search_after
  • scroll

其中:

  • scroll 更适合离线导出
  • search_after 更适合在线翻页

七、聚合查询 aggs

Elasticsearch 不只是搜索引擎,它也很擅长做统计分析。这个能力主要通过 aggs 来实现。

1. terms 聚合

统计每个品牌下有多少文档:

1
2
3
4
5
6
7
8
9
10
11
GET /product/_search
{
"size": 0,
"aggs": {
"brand_count": {
"terms": {
"field": "brand.keyword"
}
}
}
}

这里 size: 0 的意思是:

  • 不返回具体文档
  • 只返回聚合结果

2. avg 聚合

统计平均价格:

1
2
3
4
5
6
7
8
9
10
11
GET /product/_search
{
"size": 0,
"aggs": {
"avg_price": {
"avg": {
"field": "price"
}
}
}
}

3. 分组后再统计

先按品牌分组,再统计每个品牌的平均价格:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
GET /product/_search
{
"size": 0,
"aggs": {
"brand_group": {
"terms": {
"field": "brand.keyword"
},
"aggs": {
"avg_price": {
"avg": {
"field": "price"
}
}
}
}
}
}

这是报表和分析场景里非常常见的写法。

八、几个常见业务场景

1. 全文搜索 + 条件过滤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
GET /article/_search
{
"query": {
"bool": {
"must": [
{ "match": { "title": "Elasticsearch" } }
],
"filter": [
{ "term": { "status.keyword": "published" } },
{ "range": { "publish_time": { "gte": "2026-01-01" } } }
]
}
}
}

2. 精确筛选

1
2
3
4
5
6
7
8
9
10
11
GET /order/_search
{
"query": {
"bool": {
"filter": [
{ "term": { "user_id": 1001 } },
{ "term": { "status.keyword": "paid" } }
]
}
}
}

3. 搜索 + 聚合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
GET /product/_search
{
"query": {
"match": {
"title": "手机"
}
},
"aggs": {
"brand_group": {
"terms": {
"field": "brand.keyword"
}
}
}
}

这种写法在搜索页做筛选面板时特别常见。

九、最容易踩的坑

1. matchterm 用反了

这是最常见的问题。

  • text 字段,通常用 match
  • 查 ID、状态、枚举值,通常用 term

2. 对 text 字段做聚合或排序

很多人会发现:

  • 排序报错
  • 聚合结果不符合预期

原因通常是字段类型是 text,会被分词。

这时一般要用:

  • field.keyword

3. 深分页性能差

如果 from 很大,Elasticsearch 会越来越慢。

所以不是所有分页都适合一直用 from + size

4. 把过滤条件写进 must

如果某个条件只是单纯筛选,而不是为了影响相关性评分,那么它更适合写进 filter

十、一个综合示例

下面是一个比较完整的搜索 DSL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
GET /product/_search
{
"_source": ["title", "brand", "price", "category"],
"from": 0,
"size": 10,
"query": {
"bool": {
"must": [
{ "match": { "title": "iphone" } }
],
"filter": [
{ "term": { "brand.keyword": "Apple" } },
{ "range": { "price": { "gte": 5000, "lte": 10000 } } }
],
"must_not": [
{ "term": { "status.keyword": "offline" } }
],
"should": [
{ "match": { "title": "pro" } }
]
}
},
"sort": [
{ "_score": "desc" },
{ "price": "asc" }
],
"aggs": {
"category_group": {
"terms": {
"field": "category.keyword"
}
}
}
}

如果把它翻译成人话,就是:

  • 搜索标题里和 iphone 相关的商品
  • 品牌必须是 Apple
  • 价格在 5000 到 10000 之间
  • 不要下架商品
  • 命中 pro 的结果优先级更高
  • 按相关性和价格排序
  • 同时统计不同分类下的分布

十一、小结

ElasticSearch DSL 最重要的不是死记语法,而是先建立一套正确的使用习惯:

  1. 全文搜索优先考虑 match
  2. 精确过滤优先考虑 term / range / filter
  3. 复杂逻辑用 bool 来组织
  4. 统计分析用 aggs

如果把 Elasticsearch DSL 理解成“倒排索引上的 JSON 查询语法树”,很多看起来复杂的写法其实都会变得很自然。

真正写多了之后,你会发现最常用的其实就是这几类:

  • match
  • term
  • bool
  • range
  • sort
  • aggs

把这些掌握住,日常绝大多数 Elasticsearch 查询需求基本就能覆盖了。

这篇文章整理一下我们在课程项目里做的一个播客检索系统:基于 Elasticsearch,对 Spotify 的播客数据集建立索引,并支持按用户查询返回“可直接收听的播客片段”,而不是只返回整期播客。

这个项目最有意思的地方不只是“把文本搜出来”,而是我们想解决一个更具体的问题:

  • 用户搜索一个主题时,不只是想知道“哪一期播客提到了它”
  • 更希望直接拿到“从哪里开始听最合适”

也就是说,系统返回的不是 episode 级别结果,而是 clip 级别结果。

一、项目目标

这个项目的目标可以概括成一句话:

  • 给定一个用户查询,返回和该查询最相关的播客片段,并允许用户指定片段时长

例如用户搜索:

  • AI ethics
  • Financial planning retirement
  • Remote work productivity

系统希望返回的是:

  • 某一期播客里,从第几分钟开始的一段内容最相关
  • 这段内容长度是多少
  • 这段内容的文本是什么

相比传统的“返回整个播客节目”,这种形式更适合真实使用场景,因为用户可以直接从最相关的位置开始收听。

二、数据集

项目使用的是 Spotify 提供的播客数据集,这个数据集也被用于 TREC Podcasts Track。

这个数据集的几个关键特点是:

  • 规模很大,大约包含 10 万个英文播客节目
  • 主题非常广,包括新闻、体育、健康、文化、故事、生活方式等
  • 每一期播客都有转写文本 transcript
  • transcript 又被切成了多个较短 segment,通常每段大约 30 秒
  • 每个词还有对应的开始和结束时间
  • 此外还带有元数据,例如 show name、episode name、description、ID 等

在我们的实现里,并没有使用全部数据,而是只用了数据集的前两部分,也就是 0-2 和 3-5。主要原因很现实:

  • 数据量太大
  • 全量索引成本较高
  • 对课程项目来说,使用约三分之二的数据已经足够完成系统设计与评测

三、为什么选择 Elasticsearch

这个项目的后端核心是 Elasticsearch。

选择它的原因并不复杂:

  • 它本身就是成熟的全文检索引擎
  • 支持倒排索引,适合大规模文本搜索
  • 支持 BM25 排序
  • DSL 灵活,便于快速实验不同查询策略
  • 同时具备索引、检索、过滤和存储能力

对于播客检索这种任务,Elasticsearch 很合适,因为我们要解决的是:

  • 大规模文本检索
  • 查询速度不能太慢
  • 检索结果需要带时间信息和附加字段

这些能力它都比较自然地支持。

四、系统整体架构

整个系统可以粗略分成三部分:

  1. 数据索引模块
  2. 检索模块
  3. 用户界面模块

1. 数据索引模块

这一部分负责:

  • 读取播客 transcript 和 metadata
  • 将 episode 按 segment 切分
  • 为每个 segment 构建 Elasticsearch 文档
  • 建立索引

2. 检索模块

这一部分负责:

  • 接收用户查询
  • 执行 Elasticsearch 检索
  • 对命中的 30 秒 segment 做后处理
  • 拼接出用户指定时长的 clip

3. 用户界面模块

我们使用 PyQt5 做了一个桌面 GUI,主要功能包括:

  • 输入查询
  • 指定返回 clip 的时长
  • 指定返回结果数量
  • 选择搜索模式
  • 展示结果列表
  • 双击查看具体 transcript 内容

这部分不是检索算法的核心,但对完整体验很重要,因为它让系统从“离线实验”变成了“可交互原型”。

五、索引设计

这个项目里最关键的设计之一就是:我们索引的最小单位不是整期播客,而是 segment。

也就是说,Elasticsearch 中的每一条文档代表的是播客中的一小段 transcript,而不是整条 episode。

1. 每条文档包含哪些字段

每个被索引的 segment 文档大致包含:

  • show ID
  • show name
  • episode ID
  • episode description
  • segment ID
  • text
  • start time
  • end time
  • words

其中 words 这个字段比较特殊,因为它保存了 segment 中每个词的起止时间。

这对后面的 clip 拼接非常重要,因为我们不仅想返回固定 segment,还想在最后几秒做更细粒度的截断和补齐。

2. 为什么 segment 级索引比 episode 级更合适

如果直接把整期播客作为一条文档去索引,会有几个问题:

  • 文档太长,查询相关性粒度太粗
  • 即使命中,也很难知道从哪里开始听
  • 排序时只能判断“这一期播客是否相关”,而不能判断“这一段是否相关”

而用 segment 级索引后:

  • 检索粒度更细
  • 排序更接近用户真实需求
  • 更容易把结果映射回播客中的具体时间点

这也是整个系统能做“片段检索”的基础。

3. 分词与 analyzer

在索引设计中,我们对 transcript 的 text 字段使用了英文 analyzer。

这个 analyzer 会做几件事:

  • lowercasing
  • stemming
  • stop word removal

这意味着:

  • 大小写差异不会影响检索
  • 单复数、词形变化更容易被统一处理
  • 一些停用词会被忽略

同时,这个 analyzer 也会作用在查询上,从而保证:

  • 索引时怎么分词
  • 查询时就怎么分词

两边保持一致。

这个选择的好处是:

  • 检索更稳
  • 索引更紧凑
  • 搜索速度更快

但它也有代价,后面讨论局限时会再讲。

六、搜索模式设计

为了让系统支持不同检索需求,我们设计了四种查询方式:

  1. Union search
  2. Intersection search
  3. Phrase search
  4. Fuzzy search

Union search 的逻辑是:

  • 查询中至少有一个 token 命中即可

这类搜索更宽松,召回通常更高。

在 Elasticsearch 中,我们主要使用的是 multi_match

Intersection search 的逻辑是:

  • 查询中的所有 token 都必须出现在结果里

这比 union 更严格,通常会减少一些噪声结果。

实现上,我们使用了 match 查询并设置 operator = and

Phrase search 的逻辑是:

  • 不只是词要出现
  • 还要求词序基本一致

这个模式对精确短语搜索非常有用,因此我们用了 Elasticsearch 的 match_phrase

Fuzzy search 的逻辑是:

  • 即使用户拼错单词,也尽量给出合理结果

实现上,我们在 union search 的基础上增加了:

  • fuzziness = AUTO

这样系统就可以容忍一定程度的拼写错误。

5. 底层排序

无论哪种查询方式,本项目里文档排序的核心仍然是:

  • BM25

也就是说,Elasticsearch 返回的 _score 本质上就是 BM25 相关性分数。

七、如何从 30 秒 segment 拼成 n 分钟 clip

这是整个项目最有特点的部分。

因为我们的索引单位是大约 30 秒的 segment,但用户想要的是:

  • 指定长度的片段,比如 80 秒、2 分钟等

所以系统不能直接把 Elasticsearch 命中的 segment 原样返回,而必须做后处理。

1. 核心思路

整体流程是:

  1. 先用 Elasticsearch 找到最相关的小 segment
  2. 以该 segment 为起点
  3. 继续向后取同一期播客中的后续 segment
  4. 直到累计时长接近用户想要的 clip 长度

也就是说:

  • 排序基于最相关的起始 segment
  • clip 本身则通过后续 segment 补齐

2. 具体算法

对每个检索结果:

  1. 取当前命中的 segment 作为 clip 起点
  2. 读取它在同一 episode 中的下一个 segment
  3. 如果加入下一个 segment 后总时长还没有超过目标长度,就继续加
  4. 如果加完整个下一个 segment 会超长,就不整段加入,而是开始按词累加
  5. 按词累加时,利用 words 字段中的词级时间信息
  6. 当达到目标长度后,如果当前句子还没结束,就继续补到句末

这个设计有两个很明显的好处:

  • 不需要为了不同 clip 长度反复重建索引
  • 结果的第一部分总是来自最相关的命中 segment

也就是说,最相关的信息通常会出现在 clip 开头,更符合用户实际使用习惯。

3. 为什么不直接在索引阶段生成固定长度 clip

我们也考虑过另一种方案:

  • 直接在索引阶段把播客切成固定长度 clip,比如每段 2 分钟

这样做的好处是:

  • 查询时更简单
  • 不需要实时拼接

但问题也很明显:

  • clip 长度会被固定死
  • 如果用户想看 80 秒、120 秒、180 秒结果,就得重新构建索引
  • 索引过程本来就是项目里最耗时的步骤之一

所以最终我们选择了:

  • 小 segment 建索引
  • 搜索时动态拼接 clip

这个方案更灵活,也更适合交互式系统。

八、用户界面设计

前端部分我们用的是 PyQt5。

UI 的主要功能包括:

  • 输入查询文本
  • 指定 clip 长度
  • 指定返回结果数
  • 选择 union / intersection / phrase / fuzzy 四种搜索模式
  • 在表格中展示结果
  • 双击后查看 transcript

为了避免检索时间过长导致界面卡死,搜索是在单独线程里执行的。

这一点虽然不是算法创新,但对可用性很重要。因为哪怕检索只要几百毫秒,如果在 GUI 主线程里阻塞,也会让交互体验很差。

九、评测设计

为了评估系统效果,我们选了三条查询:

  • AI ethics
  • Financial planning retirement
  • Remote work productivity

并比较了两种搜索模式:

  • union
  • intersection

对于每种查询和每种搜索方式,我们取:

  • top 30
  • clip 长度为 80 秒

也就是说,每位评估者总共需要评估:

  • 6 × 30 = 180 条结果

1. 人工标注标准

每个结果都被打成四档:

  • Bad = 0
  • Fair = 1
  • Good = 2
  • Excellent = 3

这个打分标准参考了 TREC Podcast Track 的定义。

大致理解可以是:

  • Excellent:高度相关,而且是很好的切入点
  • Good:相关,但起点不是最优
  • Fair:有一定相关性,但上下文不够好
  • Bad:不相关

2. 使用的评测指标

项目里用了以下几类指标:

  • Precision@10
  • Precision@30
  • Average Precision
  • NDCG@30
  • MRR
  • Search Time

之所以没有重点看 Recall,是因为:

  • 我们只人工评估了 top 30
  • 很难估计所有未返回结果中的 false negatives

在这种前提下,Recall 信息量不大。

3. 两种聚合评估方式

三位评估者的标注结果,我们尝试了两种聚合方式。

方法一:Aggregate-then-evaluate

先把三个评估者对同一结果的分数做平均,再基于平均分计算指标。

例如:

  • mean relevance >= 0.5 的结果可视为 relevant

方法二:Evaluate-then-aggregate

先对每位评估者分别计算所有指标,再把这些指标做平均。

这两种方法的区别很重要,因为它会影响 precision 等二值指标的最终结果。

十、实验结果与观察

从结果上看,系统整体表现是比较令人满意的。

最核心的观察有几个:

1. Union 和 Intersection 差异不大

对于 AI ethics 这条查询:

  • union 和 intersection 得到了完全一样的结果

对于另外两条查询:

  • union 在大多数指标上略好一些
  • 但差距并不大

所以从这个项目的实验规模来看,可以认为:

  • union 和 intersection 的差别存在,但不显著

2. 整体指标较高

无论是 precision、NDCG 还是 MRR,整体结果都比较高,说明:

  • BM25 + transcript segment 检索
  • 再加上 clip 后处理

这个基本方案本身是可行的。

3. 搜索速度可接受

所有查询在两种搜索模式下的时间都低于:

  • 0.5 秒

这个速度对于一个课程项目原型来说已经很舒服,说明 Elasticsearch 在这个任务里性能完全够用。

十一、结果背后的问题

虽然结果总体不错,但在评测方式上我们也发现了一些有意思的问题。

1. Aggregate-then-evaluate 可能让 precision 看起来过高

我们发现:

  • 三位评估者都标出了一些“不相关结果”
  • 但这些不相关结果并不总是重合

于是当我们先平均分、再计算 precision 时,一些原本对个别评估者来说“不相关”的结果,可能因为平均分不低而被视为 relevant。

结果就是:

  • aggregate-then-evaluate 的 precision 看起来会非常高

但这未必完全真实。

2. Evaluate-then-aggregate 更保守

相比之下,先对每个评估者单独算指标,再取平均,会更保守,也更能反映不同评估者之间的分歧。

这给我们的启发是:

  • 评测不只是算公式
  • 标注人数太少时,指标解释必须谨慎

换句话说,结果虽然不错,但由于评估者只有三人,我们不能把这些指标过度解读成“系统已经非常成熟”。

十二、系统局限

这个项目虽然已经能工作,但局限也很明显。

1. 只用了 transcript,没有用 metadata

这是我们刻意做的选择。

这样做的好处是:

  • 可以更纯粹地观察 transcript 检索效果

但问题在于:

  • 有些播客的标题、节目描述本身就能提供很强信号

根据相关工作,metadata 和 transcript 结合通常会优于单独使用 transcript。

2. analyzer 有利也有弊

我们使用 English analyzer 做:

  • stop word removal
  • stemming

这在很多情况下是合理的,因为:

  • 索引更小
  • 检索更快
  • 词形变化更容易匹配

但它也可能带来问题:

  • stemming 有时会引入歧义
  • 某些 stop words 在特定查询里并不是完全没意义

所以 analyzer 选择并不是无脑越 aggressive 越好。

3. clip 排序只看起始 segment

目前 clip 的相关性排序,本质上还是由:

  • 起始 segment 的 BM25 得分

决定的。

这意味着:

  • clip 的开头很相关时,它就会排得很靠前
  • 但 clip 后半部分的信息并没有参与重新打分

这种设计是合理的,因为用户确实更在意“从哪里开始听”;但从检索系统角度看,仍然有改进空间。

十三、可以继续改进的方向

如果继续做下去,我觉得至少有三个方向很值得扩展。

1. 引入 metadata

可以把下面这些字段一起纳入检索:

  • show title
  • episode title
  • description

这样很多查询会更容易命中真正相关的节目。

2. 做 reranking

当前系统的基础排序是:

  • Elasticsearch BM25

这很强,但仍然是第一阶段召回/排序方法。

后续完全可以做成两阶段:

  1. 用 BM25 召回 top-K 候选
  2. 再用更强模型做 reranking

例如:

  • BERT reranker
  • cross-encoder
  • 其他语义匹配模型

这也是 TREC 相关工作里效果更强的路线。

3. 研究不同 clip 构造策略

目前的 clip 构造策略是:

  • 从最相关 segment 开始,向后拼接

这很自然,但未必总是最优。

后续可以探索:

  • 是否允许向前补上下文
  • 是否基于句子边界而不是 segment 边界构造
  • 是否基于完整 clip 内容重新打分

十四、小结

这个项目的核心不只是“用 Elasticsearch 做搜索”,而是把一个传统全文检索问题变成了一个更贴近用户真实需求的任务:

  • 不返回整期播客
  • 而返回可直接收听的相关片段

从实现上看,它有几个关键点:

  1. 用 segment 而不是 episode 做索引
  2. 用 BM25 做基础检索
  3. 支持 union、intersection、phrase、fuzzy 多种搜索模式
  4. 在搜索时动态拼接出用户指定长度的 clip
  5. 用人工标注和 NDCG、MRR、Precision 等指标进行评估

最终结果表明,这种“segment 检索 + 动态 clip 拼接”的方案是可行的,而且在搜索速度和效果上都达到了比较满意的水平。

如果后续再加上:

  • metadata
  • reranking
  • 更细致的 clip 重打分

这个系统其实还有很大的提升空间。

曹越,吕臣臣,孙娅苹,等. 面向车联网环境的异常行为检测机制研究综述[J]. 信息网络安全,2023,23(4):10-19.

车联网

指智能网联汽车借助新一代信息通信技术,按照规范的通信协议与网络中其他实体进行无线通信和信息交换的网络,涉及智能网联汽车车端、车联网通信和车联网服务平台

车联网通信

通信技术主要有专用短程通信(DSRC)和蜂窝车联网(C-V2X)。

通信模式主要是多播和广播。

欧洲电信标准协会定义了协同感知消息(CAM)和事件驱动的分散式环境通知消息(DENM)支持上述通信模式。美国汽车工程师协会定义了基本安全消息(BSM),其内容由两部分组成,与CAM和DENM颇为相似。上述消息类型构成了车联网通信的主要部分,主要服务于交通控制(如交通拥堵或道路施工等)。

近年来车联网环境中异常行为检测的相关研究

车联网中大多数应用程序依赖于节点间交互通信的数据,其安全功能主要聚焦保障数据的完整性和真实性。但是,传统公钥密码体制仅能检测未经授权
节点和未经签名消息,而无法检测来自拥有有效密钥对和证书的网络内部节点的攻击(如虚假消息和Sybil攻击)。

以虚假消息攻击导致的交通行为异常

1)攻击者通过操纵自身节点或入侵正常节点以获得可操纵的恶意节点

2)恶意节点在车联网通信中传播实际并未在位置X发生的交通事件消息

3)接收节点将基于此虚假消息改变行驶路线以避让虚假交通事件

发出虚假消息的是一个被授权的节点,但是被攻击者攻击了

Sybil攻击

异常行为定义

一般情况下与交通控制相关的消息易面临被动窃听和分析、主动篡改和伪造等攻击。此外,由于车联网通信依赖参与节点间合作,也更易受到恶意参与节点的攻击

常见威胁模型

异常行为检测机制

基于消息内容的异常检测

  1. 基于合理性的异常检测
  2. 基于一致性的异常检测
  3. 基于入侵检测系统的异常检测

基于消息处理行为的异常检测

  1. 基于看门狗的异常检测
  2. 基于自定义规则的异常检测
  3. 基于信任管理的异常检测

结合传感器的异常检测

当前车联网通信异常行为检测机制中尚未解决的技术问题和未来研究趋势

上一篇主要讨论了词典分词。词典分词可以解决“字符串能否成词”的问题,但无法很好处理切分歧义。例如“研究生命起源”既可以切成“研究 / 生命 / 起源”,也可以切成“研究生 / 命 / 起源”。两条路径可能都能在词典中找到对应词语,因此还需要一个标准来判断哪一种切分更合理。

二元语法模型(Bigram Language Model)正是这种标准中最基础的一种。它利用相邻两个词的共现概率,对候选切分路径进行打分。

问题定义

设一句话对应的分词结果为:

W = w1, w2, ..., wn

理想情况下,我们希望选择概率最大的那条切分路径:

W* = arg max P(W)

其中 P(W) 表示该词序列作为自然语言出现的概率。

但直接估计:

P(w1, w2, ..., wn)

几乎不可行,因为词序列空间过大,语料中很难覆盖所有组合。因此需要对联合概率进行近似建模。

马尔可夫假设与二元语法

由链式法则可得:

P(w1, w2, ..., wn) = P(w1) P(w2|w1) P(w3|w1,w2) ... P(wn|w1,...,wn-1)

若采用一阶马尔可夫假设,即认为当前词只依赖于前一个词,则有:

P(wi|w1,...,wi-1) ≈ P(wi|wi-1)

于是整句概率近似为:

P(W) ≈ P(w1) ∏(i=2..n) P(wi|wi-1)

这就是二元语法模型。它的核心在于估计条件概率 P(wi|wi-1)

参数估计

设:

  • C(wi-1, wi) 表示二元词对 (wi-1, wi) 在语料中的出现次数
  • C(wi-1) 表示词 wi-1 在语料中的出现次数

则最大似然估计为:

P(wi|wi-1) = C(wi-1, wi) / C(wi-1)

句首词的概率可以写为:

P(w1) = C(w1) / N

其中 N 为语料中的总词数,或者也可以显式加入句子起始标记 <BOS>,把首词概率改写为:

P(w1|<BOS>)

在实现中,加入 <BOS><EOS> 往往更统一,也更方便写动态规划。

为什么二元语法适合中文分词

中文分词的本质,是在一个句子对应的多条候选切分路径中找到最优路径。词典告诉我们“哪些片段可能是词”,二元语法告诉我们“哪些词连接在一起更自然”。

例如句子:

南京市长江大桥

候选切分可以写成:

  1. 南京市 / 长江大桥
  2. 南京 / 市长 / 江大桥

若单看词典,第二条路径未必在形式上完全非法;但从统计语言模型角度看:

P(长江大桥|南京市)

通常远大于:

P(市长|南京) * P(江大桥|市长)

因此第一条路径会获得更高得分。

分词中的打分函数

在中文分词任务中,我们最终想求的是:

W* = arg max [P(w1) ∏(i=2..n) P(wi|wi-1)]

由于多个小概率连乘容易造成数值下溢,实际中通常取对数:

W* = arg max [log P(w1) + Σ(i=2..n) log P(wi|wi-1)]

等价地,也可以写成最小化负对数形式:

W* = arg min [-log P(w1) - Σ(i=2..n) log P(wi|wi-1)]

这样一来,中文分词就转化成了一个路径搜索问题:

  1. 节点表示候选词
  2. 边表示相邻词之间的转移
  3. 边权由 -log P(wi|wi-1) 给出
  4. 最终寻找总代价最小的路径

这个过程可以结合 DAG、动态规划或 Viterbi 算法来完成。

一个简单例题

考虑句子:

商品和服务

假设词典给出的两条候选路径为:

  1. 商品 / 和 / 服务
  2. 商 / 品和 / 服务

进一步假设语料统计如下:

  • C(商品) = 100
  • C(和) = 200
  • C(服务) = 150
  • C(商品, 和) = 80
  • C(和, 服务) = 120
  • C(商, 品和) = 1
  • C(品和, 服务) = 0

则有:

P(和|商品) = 80 / 100 = 0.8

P(服务|和) = 120 / 200 = 0.6

于是路径 1 的条件概率部分约为:

0.8 * 0.6 = 0.48

而路径 2 中,由于:

P(服务|品和) = 0

所以整条路径概率直接变为 0。可见二元语法会明显偏向第一种更自然的切分结果。

平滑

上述估计有一个经典问题:数据稀疏。若某个二元词对没有在训练语料中出现,则最大似然估计给出的概率为 0:

P(wi|wi-1) = 0

这会导致只要路径中有一条未见过的转移,整条路径概率就归零。为了缓解这个问题,需要做平滑。

最简单的方式是加一平滑:

P(wi|wi-1) = (C(wi-1, wi) + 1) / (C(wi-1) + |V|)

其中 |V| 表示词表大小。

虽然加一平滑并不是最优方法,但在课程练习或入门实现里,它足够直观。更常见、效果更好的方法还包括:

  1. Good-Turing
  2. Katz Backoff
  3. Kneser-Ney

与词典分词的结合

在工程实现中,二元语法通常不会单独工作,而是和词典匹配一起使用。一个典型流程如下:

  1. 使用词典或 Trie 枚举所有可能词语
  2. 构建句子的候选词图
  3. 用二元语法为相邻候选词赋予转移概率
  4. 使用动态规划寻找最优路径

因此可以把整个分词器理解为两层:

  1. 词典层负责“召回候选词”
  2. 语言模型层负责“排序候选路径”

上一篇偏重第一层,这一篇偏重第二层。

局限性

二元语法模型虽然简单有效,但它有明显的表达上限。

  1. 只考虑前一个词,无法建模更长距离的依赖
  2. 对语料规模较敏感,小语料下统计不稳定
  3. 对未登录词仍然不够友好

例如有些歧义需要结合更长上下文才能解决,这时二元语法往往不够,需要进一步引入:

  1. 三元语法
  2. HMM
  3. CRF
  4. 神经网络语言模型

小结

二元语法的核心思想可以概括为一句话:用相邻词的共现概率来近似刻画整条分词路径的合理性。

其基本公式为:

P(W) ≈ P(w1) ∏(i=2..n) P(wi|wi-1)

在中文分词中,我们并不是单纯判断“某个片段是不是词”,而是在所有候选切分路径中寻找概率最大的那一条。因此,二元语法实际上把中文分词从“字符串匹配问题”推进成了“统计建模 + 路径搜索问题”。

继续往后学习时,一个自然的问题就是:如何把二元语法与 Viterbi 解码结合,真正实现一个可运行的统计分词器。

齐夫定理

一个单词出现的频率与它在频率表里的排序成反比。

例如,在英语的 Brown 语料库中,「the」、「of」、「and」是出现频率最高,排序 1、2、3 的单词,分别占整个语料库100万个单词数的 7%、3.6%、2.9%[1]。可见排序第2位「of」的频率大约是第1「the」的1/2,第3的「and」是其 1/3。以此类推,排序第n单词的频率是最常见频率的1/n。最简单的齐夫定律排序遵从一次反比即 1/f 关系。由此可以得到它的等价描述:

在给定语料中,对于任意一个单词,其频率(Frequency)与频率排序(Rank)乘积大致是一个常数,即:Rank * Frequency ≈ Constant

因此虽然语言中的词汇数量是无穷的,无法用任何词典完整收录,但将单词词频降序排列,由齐夫定律可得,越靠后词频越小,趋近于0,平时很难碰到,因此可以尝试词典分词。

切分算法

  1. 完全切分
  2. 正向最长匹配
  3. 逆向最长匹配
  4. 双向最长匹配

字典树

匹配算法的瓶颈之一在于如何判断集合(词典)中是否含有字符串。

字典树中每条边都对应一个字, 从根节点往下的路径构成一个个字符串。字典树并不直接在节点上存储字符串, 而是将词语视作根节点到某节点之间的一条路径,并在终点节点(蓝色) 上做个标记“该节点对应词语的结尾”。字符串就是一 条路径,要查询一个单词,只需顺着这条路径从根节点往下走。如果能走到特殊标记的节点,则说明该字符串在集合中,否则说明不存在。一个典型的字典树如下图所示所示。

dictree

字典树也可以实现映射,只需将相应的值悬挂在键的终点节点上即可。

字典树的增删查改

首字散列其余二分的字典树

双数组字典树

双数组字典树是一种状态转移复杂度为常数的数据结构。

由base和check两个数组构成(base和check的索引表示一个状态),缩短状态转移过程的时间。

具体的,当状态b接受字符c转移到状态p时,满足条件(状态由整数下标表示):

state[p] = base[state[b]] + index[c]

check[state[p]] == state[b]

若条件不满足则转换失败。

如:当前状态自然(例如state[自然]=1),若想判断是否可以转移到状态自然人,先执行state[自然人] = base[state[自然]] + index[人] = base[1] + index[人],然后判断check[state[自然人]] == state[自然]是否成立即可,仅需一次加法和整数比较就能进行状态转移,转移过程为常数事件。

doudictree

参考手把手教学 | 双数组字典树 - 知乎 (zhihu.com)

AC自动机

在使用字典树进行全切分时,每次都需要不断挪动起点,发起新的查询,这会显著增加时间复杂度,为了实现一次扫描就查询出所有出现过的单词,发明了AC自动机,它是常用的多模式匹配算法。

AC自动机使用前缀树来存放所有模式串的前缀,通过失配指针来处理失配的跳转。AC自动机的构建,首先需要构建Trie树,其次需要添加失配指针(fail表),最后需要模式匹配。下图是用单词her、say、she、shr、he构成的AC自动机。

ACauto

参考地铁十分钟 | AC自动机 - 知乎 (zhihu.com)

goto表

就是一棵前缀树,用来将每个模式串索引到前缀树上

output表

给定一个状态,我们需要知道该状态是否对应某个或某些字符串, 以决定是否输出模式串以及对应的值

fail表

存储的应该是fail指针,用来存储状态转移失败后应当回退的最佳状态,最佳状态指的是能记住已匹配上的字符串的最长后缀的那个状态。

基于双数组字典树的AC自动机

准确度评测

混淆矩阵

精确率

召回率

F1值

用python爬取豆瓣网页

python作为一种已经广泛传播且相对易学的解释型语言,现如今在各方面都有着广泛的应用。而爬虫则是其最为我们耳熟能详的应用,今天笔者就着重针对这一方面进行介绍。

python 语法简要介绍

python 的基础语法大体与c语言相差不大,由于省去了c语言中的指针等较复杂的结构,所以python更被戏称为最适合初学者的语言。而在基础语法之外,python由其庞大的第三方库组成,而其中包含多种模块,而通过模块中包含的各种函数与方法能够帮助我们实现各种各样的功能。

而在python爬虫中,我们需要用到的标准库有:

  • urllib
  • re
  • bs4
  • xlwt

其中urllib库可以帮助我们爬取目标网页的html代码,bs4中的beautifulsoup模块以及re库中的正则表达式可以将我们需要的数据从代码中提取出来,而xlwt库可以将数据储存至excel表中,从而最终完成数据的爬取。

接下来,就步入我们此次介绍的重点——完整爬取一个网页的数据。

本篇文章以爬取豆瓣电影top250的数据为例,并将爬取的过程分为三个部分:

1.爬取网页

2.解析网页

3.储存网页

那么,让我们开始吧!

豆瓣top250网址:https://movie.douban.com/top250?start=

爬取网页

1
import urllib.request

引入urllib库中的request模块

urllib库的基本操作可参考该网址:

https://www.cnblogs.com/qikeyishu/p/10748497.html

1
2
3
4
5
6
7
8
9
10
11
12
def askURL(url):
head = {
"User-Agent": "Mozilla / 5.0(Windows NT 10.0;Win64;x64) AppleWebKit / 537.36(KHTML, likeGecko) Chrome / 88.0.4324.182Safari / 537.36"
}# 模拟浏览器的登录
request = urllib.request.Request(url,headers=head)
#将网页的url和头部信息封装至一起
response = urllib.request.urlopen(request)
#获取网页的html代码
html = response.read().decode("utf-8")
#将获取的数据转化为utf-8格式
#print(html) #此步可以实验一下能否成功爬取网页的html代码
return html

1.其中urllib.request.Request可以帮我们把要爬取的网页的url及其他的头部信息封装至一起。

2.urlopen函数则可以帮助我们爬取下该网页的html代码

3.有一些网站会设置一下反爬机制来阻止我们的爬虫,此时就需要我们设置头部信息来模拟浏览器访问网站

QQ截图20210406215713

需要用浏览器进入该网址,使用开发者模式获取我们需要的头部信息(也就是该图中的user-agent)

4.最后需要将我们的爬取下的html代码转化为utf-8格式进行输出

解析网页

1
2
import re
from bs4 import BeautifulSoup

引入re库和bs4库

beautifulsoup模块的基本操作可参考该网址:

http://www.jsphp.net/python/show-24-214-1.html

re库的基本操作可参考该网址:

https://www.runoob.com/python3/python3-reg-expressions.html

1
2
3
4
5
6
7
8
9
10
def getData(baseurl):
datalist = [] #建立一个存放解析出的数据的元组
for i in range(0,10):
url = baseurl + str(i*25)
# 通过以下两张截图,我们可以发现豆瓣将每25部电影分为一页,共分成了10页、
# 而其url的差别仅在最后加了25,故通过该规律,可将所有10张网页的url全部获取
html = askURL(url)
soup = BeautifulSoup(html,"html.parser")
#通过beautifulsoup模块自带的html代码解析器进行解析
#并将解析器解析出的数据放至soup中

QQ截图20210403122757QQ截图20210407153132

逐页进行解析,使解析出的数据能被我们接下来要使用的正则表达式识别

所谓正则表达式,就是对字符串操作的一种逻辑公式,就是用事先定义好的一些特定字符、及这些特定字符的组合,组成一个“规则字符串”,这个“规则字符串”用来表达对字符串的一种过滤逻辑,通过这种过滤,就可以得到我们想要的信息,就例如影片的名称,评分等信息。

1
2
3
4
5
6
7
8
findlink = re.compile(r'<a href="(.*?)">')  # r表示不受转义字符的影响
#该代码通过正则表达式搜寻到所有关于影片链接的数据,以下代码类似
findImgSrc = re.compile(r'<img.*src="(.*?)"',re.S) #让换行符包含在字符中
findtitle = re.compile(r'<span class="title">(.*)</span>')
findscore = re.compile(r'<span class="rating_num" property="v:average">(.*)</span>')
findjudge = re.compile(r'<span>(\d*)人评价</span>')
findinq = re.compile(r'<span class="inq">(.*)</span>')
findbd = re.compile(r'<p class="">(.*?)</p>',re.S)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
    for item in soup.find_all("div",class_="item"):
#提取所有class为“item”的div模块,并通过for循环一步步处理
data = []
item = str(item) #将item转化为字符串类型
link = re.findall(findlink,item)[0]
#通过影片详情链接的正则表达式抽取数据
data.append(link)
#存放至data列表中
ImgSrc = re.findall(findImgSrc,item)[0]
data.append(ImgSrc)
title = re.findall(findtitle,item)
if len(title)==2:
#如果影片有多个名称,则分别进行存储
ctitle = title[0]
data.append(ctitle)
otitle = title[1].replace("/","")
data.append(otitle)
else:
data.append(title[0])
data.append(" ")
score = re.findall(findscore,item)
data.append(score)
judge = re.findall(findjudge,item)
data.append(judge)
inq = re.findall(findinq,item)
if len(inq)!=0:
inq = inq[0].replace("。","")
data.append(inq)
else:
data.append("")
#若有影片详情,则输出;若没有,则输出为空
bd = re.findall(findbd,item)[0]
bd = re.sub('<br(\s+)?/>(\s+)?'," ",bd)
bd = re.sub('/'," ",bd)
data.append(bd.strip()) # 去掉前后空格

datalist.append(data)
print(datalist)
return datalist

以上代码能通过正则表达式抽取出需要的数据存放data列表中,然后将所有的data数据存放至datalist列表中。

储存网页

将解析出的数据储存到excel表中

1
import xlwt

引入xlwt库

xlwt的基本操作可参考该网址:

https://www.cnblogs.com/caesar-id/p/11802440.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def savepath(datalist):
workbook = xlwt.Workbook(encoding="utf-8")
#创建以utf-8格式编码的一个workbook对象,该对象最后能保存为excel表格
worksheet = workbook.add_sheet("sheetwdy")
#创建工作表“sheetwdy”
col = ("电影详情链接", "图片链接", "影片中文名", "影片外国名", "评分", "评价数", "概况", "相关信息")
#创建一个元组
for i in range(0, 8):
worksheet.write(0, i, col[i])
# 将我们刚定义的元组中的信息写入excel表的第一行
for i in range(0, 250):
print("第%d条" % (i + 1))
data = datalist[i]
for j in range(0, 8):
worksheet.write(i + 1, j, data[j])
#将解析出的数据通过for循环一条条导入excel表中
workbook.save("豆瓣250.xls")
#将该excel表进行保存

如此我们便可以把解析出的数据存储至excel表中了

QQ截图20210403122221

以上便为成品图