密西根大学-Python-应用数据科学笔记-全-

密西根大学 Python 应用数据科学笔记(全)

1:文本挖掘导论

概述

在本节课中,我们将要学习文本挖掘的基础知识。文本数据无处不在,从书籍、报纸到社交媒体,其数量正以惊人的速度增长。我们将探讨文本数据的特点、其蕴含的丰富信息,以及文本挖掘可以解决哪些实际问题。

文本数据无处不在

文本存在于我们生活的方方面面。你可以在书籍和印刷材料中看到它们,例如报纸。维基百科和其他百科全书也由文本构成。人们在在线论坛和讨论组中相互交流,这些也是文本。Facebook和Twitter上的内容大部分也是文本。

文本数据的爆炸式增长

这些文本数据正在飞速增长。它呈指数级增长,并且持续增长。据估计,每天产生的文本数据约为2.5艾字节,即250万太字节。根据最近的估计,到2020年,这一数字将增长到约40泽字节,即400亿太字节,这大约是10年前的50倍。

非结构化文本的主导地位

大约80%的数据被估计为非结构化的自由文本。这包括维基百科中超过4000万篇文章,其中超过500万篇是英文文章。实际上,仅英文文章就有500万篇。此外,还有45亿个网页,每天约5亿条推文(每年约2000亿条),以及谷歌每年超过1.5万亿次的搜索查询。

文本中蕴含的信息

当我们审视数据,观察隐藏在文本中的信息时,你会发现它揭示了大量内容。例如,这是联合国发言人的推特个人资料。这里有作者信息、个人描述、所在地点。推文本身,即实际内容,包含了主题和每条推文的情感倾向。对于每条推文,都有其发送时间戳。还有其受欢迎程度,即被转发或点赞的次数。总的来说,这也能让你了解其社交网络状况,例如有多少人关注这个账号,以及这个联合国发言人账号关注了多少其他账号。

文本挖掘能做什么

那么,如何处理所有这些文本呢?你可以解析文本,尝试理解其含义,从中查找并提取相关信息。你甚至可以定义什么是相关信息。你需要对文本文档进行分类。你需要搜索相关的文本文档,这属于信息检索领域。你还需要进行某种情感分析,判断内容是积极的还是消极的,是快乐的、悲伤的还是愤怒的,这些都是与特定文本片段相关的情感。此外,你还可以进行主题建模,识别正在讨论的主题是什么,以及文档中讨论了几个主题等。

课程预告

在接下来的几个模块中,我们将逐一讨论这些内容,并看看如何使用Python进行文本挖掘。

总结

本节课中,我们一起学习了文本数据的重要性及其海量规模。我们了解到,大部分数据是非结构化的文本,其中蕴含着主题、情感、网络关系等多维度信息。文本挖掘技术,如信息提取、分类、检索、情感分析和主题建模,是解锁这些信息价值的关键。在后续课程中,我们将深入探索如何使用Python工具来实现这些功能。

2:Python文本处理

概述

在本节课中,我们将学习如何在Python中处理文本数据。我们将从文本的基本构成单位开始,逐步学习如何操作字符串、单词和字符,并了解如何从文件中读取和处理较大的文本。

文本的基本构成

上一节我们介绍了课程的整体目标,本节中我们来看看文本的基本构成单位。

文本由句子或字符串构成。句子由单词或标记构成。单词由字符构成。另一方面,我们还有文档和更大的文件。我们将讨论所有这些结构及其属性。

让我们尝试一下。我们从联合国发言人的个人资料中提取一句话,并将其命名为text1

text1 = "ethics are built right into the ideals and objectives of the United Nations"

如果你找出text1的长度,它会告诉你这个字符串中有多少个字符,结果是76。

如果你想找出单词,你需要分割这个文本。假设我们以空格进行分割,这是我们最基础的标记化方法。因此,你通过空格分割这个句子来找出单词或标记。

text2 = text1.split()
len(text2) # 结果是13

这个句子中有13个标记。这些标记是:ethics, are, built, right, into, the, ideals, and, objectives, of, the, United, Nations。这些看起来都是有效的单词,所以这种分割方法效果很好。

查找特定单词

上一节我们学习了如何分割文本得到单词,本节中我们来看看如何根据条件查找特定的单词。

以下是查找特定类型单词的方法:

  • 查找长单词:要查找长度超过3个字符的单词,可以使用列表推导式。
    [w for w in text2 if len(w) > 3]
    
    这将返回text2中所有长度超过3个字符的单词,例如:ethics, built, right, into等。

  • 查找首字母大写的单词:首字母大写的单词是指以大写字母A到Z开头的单词。你可以使用.istitle()函数,它会检查第一个字符是否大写而其余字符小写。

    [w for w in text2 if w.istitle()]
    

    这将告诉你哪些单词的.istitle()为真,例如:Ethics, United, Nations,否则为假。

  • 查找以特定字母结尾的单词:要查找以s结尾的单词,可以使用.endswith()方法。

    [w for w in text2 if w.endswith('s')]
    

    这将返回:ethics, ideals, objectives, nations。

查找唯一单词

上一节我们学习了如何查找单词,本节中我们来看看如何找出文本中不重复的唯一单词。

我们需要使用set函数来实现这个功能。让我们看另一个例子,text3,这是一句著名的短语。

text3 = "To be or not to be"

如果你用空格分割它,你会得到6个单词:['To', 'be', 'or', 'not', 'to', 'be']

现在,如果你使用set函数,它会找出这个列表中的所有唯一单词。

text4 = text3.split()
set(text4) # 结果是 {'be', 'To', 'or', 'to', 'not'}
len(set(text4)) # 结果是 5

我们期望得到4个唯一单词,但答案是5,发生了什么?如果你查看set(text4),你会看到确实有to, be, or, not,但to出现了两次,一次是大写T,另一次是小写t

这是一个问题,因为你不会希望仅仅因为一个单词是首字母大写就产生两个变体。

为了解决这个问题,我们应该将文本转换为小写。

set(w.lower() for w in text4) # 结果是 {'be', 'or', 'not', 'to'}
len(set(w.lower() for w in text4)) # 结果是 4

如果你打印整个集合,它确实是to, be, or, not(以某种顺序)。

字符串检查方法

上一节我们使用了.istitle().endswith()等方法,本节中我们更详细地了解一些其他的字符串检查函数。

我们有以下用于检查字符串属性的方法:

  • .startswith(prefix):检查字符串是否以指定前缀开头。
  • .endswith(suffix):检查字符串是否以指定后缀结尾,我们之前用endswith('s')演示过。
  • substring in string:使用in操作符来查找一个子字符串是否存在于一个更大的字符串中。
  • .isupper():检查字符串中所有字符是否都是大写。
  • .islower():检查字符串中所有字符是否都是小写。
  • .istitle():检查字符串是否为首字母大写形式(第一个字符大写,其余小写)。
  • .isalpha():检查字符串是否仅由字母组成。
  • .isdigit():检查字符串是否仅由数字(0-9)组成。
  • .isalnum():检查字符串是否仅由字母和数字组成。

字符串操作

上一节我们介绍了字符串的检查方法,本节中我们来看看更多可以对字符串本身进行的操作。

我们有以下常用的字符串操作方法:

  • .lower():接收一个字符串s,返回其小写版本。
  • .upper():将字符串转换为大写。
  • .title():将字符串转换为标题格式(每个单词首字母大写)。
  • .split(sep):在指定的分隔符sep处分割句子s。例如,在空格处分割(sep为单个空格字符),我们将得到单词列表。
  • .splitlines():在换行符(\n)处分割字符串。
  • .join(iterable):与.split()相反。它接受一个由字符串组成的可迭代对象(如列表或集合),并使用调用该方法的字符串s作为连接符将它们连接起来。
  • .strip():移除字符串开头和结尾的所有空白字符(包括空格和制表符)。
  • .rstrip():仅移除字符串结尾的空白字符。
  • .find(sub):从字符串开头查找子字符串sub,返回其首次出现的索引位置。
  • .rfind(sub):从字符串末尾开始查找子字符串sub,返回其首次出现的索引位置。
  • .replace(old, new):接受两个参数oldnew,将字符串s中所有出现的子字符串old替换为new

操作示例:分割与连接

让我们通过例子来看看这些操作如何工作。首先看从单词到字符的转换。

text5 = "Ouagadougou" # 布基纳法索的首都

你在这个单词text5上以"Ou"进行分割。你期望看到什么?

text6 = text5.split("Ou")
print(text6) # 输出:['', 'agad', 'g', 'ou']

text5中,当你用"Ou"分割时,会得到4个部分。第一个是空字符串,因为字符串text5"Ou"开头,所以在那之前没有任何内容。然后在第一个"Ou"和第二个"Ou"之间,你有"agad",这是集合中的第二个元素。接着是"g",第三个。最后,"ou"是单词的最后一部分,所以之后也没有内容,因此第四个也是空字符串。

所以,当一个特定字符串(本例中是"Ou")在文本中出现三次时,分割后会得到四个部分:第一次出现之前、第一次和第二次之间、第二次和第三次之间、第三次之后。

现在,如果你用字符串"Ou"来连接text6(这个包含四个元素的数组):

joined_text = "Ou".join(text6)
print(joined_text) # 输出:'Ouagadougou'

你将得到Ouagadougou,这正是我们开始分割的单词。分割和连接是互逆操作。

获取单词中的字符

假设我们想获取一个单词中的所有字符。

我们可能会想通过分割来获取,比如在空字符串上分割。

# text5.split("") # 这会报错:ValueError: empty separator

实际上,这会得到一个错误,提示是空分隔符。所以这行不通。

正确的方法是使用list()函数。

list(text5) # 输出:['O', 'u', 'a', 'g', 'a', 'd', 'o', 'u', 'g', 'o', 'u']

另一种方法是使用循环:

[c for c in text5] # 输出:['O', 'u', 'a', 'g', 'a', 'd', 'o', 'u', 'g', 'o', 'u']

因此,有两种方法可以从单词中获取字符:一种是使用list()函数,另一种是使用[c for c in text5]

文本清理示例

现在,让我们看一些清理文本的例子。

text8 = "   A quick brown fox jumped over the lazy dog.   "

这个字符串前后有一些空白字符。当你按空格分割时,由于前后有空白字符,你会在开头和结尾得到空字符串。

text8.split(' ') # 开头和结尾会有空字符串元素

这不是我们想要的结果。我们应该先使用.strip()方法。

cleaned_text = text8.strip()
cleaned_text.split(' ') # 现在会正确地得到单词列表:['A', 'quick', 'brown', 'fox', 'jumped', 'over', 'the', 'lazy', 'dog.']

.strip()会移除字符串开头和结尾的所有空白字符。然后你再按空格分割,就能正确得到单词。

如果你想进行查找和替换操作:

text9 = "A quick brown fox jumped over the lazy dog."

假设我们想查找字符'o'

text9.find('o') # 输出:10

text9.find('o')会返回它找到的第一个'o'的字符偏移量(索引)。在这个字符串中,它是第10个字符(索引从0开始)。

同样,你可以使用.rfind()进行反向查找。

text9.rfind('o') # 输出:40

这将返回字符索引40,因为从第一个'A'(索引0)开始,到'dog.'中的'o',是第40个字符。

最后,你可以进行替换,比如将所有小写'o'替换为大写'O'

text9.replace('o', 'O') # 输出:'A quick brOwn fOx jumped Over the lazy dOg.'

这将给出相同的句子,但其中所有四个出现的'o'都被替换成了'O'

这演示了如何使用.find().rfind().replace()来查找和修改文本。

处理大型文本文件

上一节我们处理的是内存中的字符串,本节中我们来看看如何处理存储在文件中的大型文本。

大型文本通常存储在文件中,因此你需要读取文件,例如逐行读取。

我们以名为UNhdr.txt的文件为例,这是《世界人权宣言》。

f = open('UNhdr.txt', 'r') # 以读取模式打开文件
first_line = f.readline()
print(first_line) # 输出类似:'Universal Declaration of Human Rights\n'

f.readline()会读取第一行,其中包括末尾的\n来表示行结束。

如果你想读取整个文件,可以循环读取每一行,或者一次性读取。因为我们已经打开并读取了一行,为了重置读取指针到文件开头,我们使用f.seek(0)

f.seek(0) # 重置读取位置到文件开头
text12 = f.read() # 读取整个文件内容
len(text12) # 输出:10891 (字符数)

f.read()会读取整个文件并将其作为字符串返回。text12的长度是10891个字符。

然后,你可以对text12使用.splitlines()来获取由\n分隔的158行。

lines = text12.splitlines()
len(lines) # 输出:158
print(lines[0]) # 输出:'Universal Declaration of Human Rights' (注意没有\n)

你会注意到,当你使用.splitlines()分割时,\n字符消失了,因为你是基于它进行分割的。而之前使用f.readline()读取单行时,末尾包含\n

通常,你会使用以下文件操作:

  • open(filename, mode):打开文件。模式'r'表示读取,'w'表示写入。
  • f.readline():读取一行。
  • f.read()f.read(N):读取整个文件,或读取接下来的N个字符。
  • 循环读取for line in f:
  • f.seek(0):将读取位置重置回文件开头。
  • f.write(message):如果你以写入模式打开文件,可以用此方法写入内容。
  • f.close():关闭文件句柄。这是打开文件的反向操作。
  • f.closed:检查文件句柄是否已关闭。

当你读取文件时,注意到f.readline()在末尾给出了\n。我们可能不想保留它。如何移除最后一个换行符?你可以使用.rstrip()

first_line_stripped = first_line.rstrip()
print(first_line_stripped) # 输出:'Universal Declaration of Human Rights'

.rstrip()用于从字符串末尾移除空白字符,而\n就是其中之一。这个.rstrip()方法适用于各种换行符(如\r\r\n),因此它是一个通用函数,比直接查找\n更好,因为后者可能因编码不同而有所变化。

总结

本节课中我们一起学习了如何处理文本句子。我们看到了如何将句子分割成单词,以及将单词分割成字符的两种方法。我们学习了如何查找唯一单词,并简要了解了如何处理来自文档或大型文件的文本。

下一步

接下来,我们将更详细地探讨如何处理文本,以从中发现一些有趣的概念。

3:正则表达式应用

在本节课中,我们将要学习正则表达式。在处理自由文本时,我们经常会遇到需要使用正则表达式或模式匹配的场景。

为什么需要正则表达式?🤔

让我们来看一个例子。这是一条来自联合国发言人账户的推文:

Ethics are built right into the ideals and objectives of the United Nations you have seen this earlier today and then you have hashtag #UNSG and there is another little piece of text like at New York Society for ethical culture, there is a URL there and then two callouts to @UN and @UN_Women.

从这条推文中,如果我们想找出所有的提及(@)和话题标签(#),该如何操作?首先,当然需要按空格分割文本,得到独立的词元。完成这一步后,我们就处于一个更有利的位置来识别提及和话题标签了。

你可以尝试一下。当寻找特定词汇如提及或话题标签时,你可能需要观察它们遵循的模式。例如,话题标签以井号(#)开头。你可以使用类似这样的代码:

[w for w in text.split() if w.startswith('#')]

很好,你得到了 #UNSG,这正是我们想要的。

那么提及呢?同理,提及是以“@”符号开头的字符串。所以你可以写:

[w for w in text.split() if w.startswith('@')]

你会得到 @UN@UN_Women。但请注意,文本中还有一个单独的“at”单词(来自“at New York Society”),它也被分割成了一个独立的词元“at”。这并非一个真正的提及。

构建更精确的模式 🎯

仅仅以“@”开头并不足以定义一个有效的提及。有效的提及(如 @UN_Spokesperson@katyperry@Coursera)在“@”符号后必须跟随一些内容。

观察这些例子,你会发现“@”后面需要匹配字母、数字或特殊符号(如下划线)。因此,模式是:以“@”开头,后跟一个或多个大写字母、小写字母、数字或下划线。

让我们用正则表达式来尝试。首先导入 re 模块,然后使用 re.search 来搜索符合该模式的词元:

import re
pattern = r'@[A-Za-z0-9_]+'
callouts = [w for w in text.split() if re.search(pattern, w)]

这样,你就能从推文中正确提取出两个提及。

正则表达式元字符详解 📖

在上面的正则表达式 @[A-Za-z0-9_]+ 中,我们使用了一些特殊符号(元字符):

  • [A-Za-z0-9_]:方括号表示匹配其中任意一个字符。这里匹配任意字母(大小写)、数字或下划线。
  • +:加号表示前面的元素(即方括号内的字符集)必须出现一次或多次

正则表达式拥有丰富的元字符来表达复杂的模式。以下是核心概念的总结:

基本匹配

  • .:通配符,匹配任意单个字符(除换行符)。
  • ^:匹配字符串的开始
  • $:匹配字符串的结束

字符集

  • [abc]:匹配方括号内的任意一个字符(a、b 或 c)。
  • [a-z]:匹配指定范围内的任意一个字符。
  • [^abc]:匹配不在方括号内的任意一个字符(非 a、b、c)。
  • (a|b):匹配 a b(a 和 b 本身可以是字符串)。

特殊序列

  • \:转义字符,用于匹配特殊字符本身,如 \. 匹配句点。
  • \t, \n:匹配制表符、换行符。
  • \b:匹配单词边界。
  • \d:匹配任意数字,等价于 [0-9]
  • \D:匹配任意非数字,等价于 [^0-9]
  • \s:匹配任意空白字符(空格、制表符、换行符等)。
  • \S:匹配任意非空白字符
  • \w:匹配任意字母数字字符,等价于 [A-Za-z0-9_]
  • \W:匹配任意非字母数字字符

重复匹配

  • *:匹配前面的元素零次或多次
  • +:匹配前面的元素一次或多次(至少一次)。
  • ?:匹配前面的元素零次或一次(可选)。
  • {n}:匹配前面的元素恰好 n 次
  • {n,}:匹配前面的元素至少 n 次
  • {n,m}:匹配前面的元素至少 n 次,至多 m 次

正则表达式实践:查找元音 🔍

让我们看更多例子。假设我们要在单词“Ouagadougou”(布基纳法索首都)中查找所有元音。

re.findall(r'[AEIOUaeiou]', 'Ouagadougou')

这将返回所有元音字母 ['O', 'u', 'a', 'a', 'o', 'u', 'o', 'u']

如果想找辅音(非元音),可以使用:

re.findall(r'[^AEIOUaeiou]', 'Ouagadougou')

这将返回 ['g', 'd', 'g']

挑战:匹配多种日期格式 📅

日期在自由文本中有多种变体,是练习正则表达式的绝佳案例。以“2002年10月23日”为例,它可能有以下写法:

  • 23-10-2002
  • 23/10/02
  • 10/23/2002
  • 23 October 2002

如果我们想写一个正则表达式来匹配它们,可能会从简单的模式开始:\d{2}[/-]\d{2}[/-]\d{2,4}。这个模式可以匹配前三种格式(两位数的日、月,以及两位或四位的年)。

但要匹配“23 October 2002”这种月份为英文的情况,模式需要更复杂:

pattern = r'\d{2} (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]* \d{4}'

这里,(Jan|Feb|...|Dec) 使用管道符 | 表示“或”,匹配月份的缩写。[a-z]* 匹配月份缩写后可能跟的小写字母(如“October”中的“ober”)。

注意:默认情况下,圆括号 () 不仅有分组作用,还会捕获匹配到的内容。如果我们只想分组而不捕获,可以使用 (?:...) 语法。例如,(?:\d{2} )?(Jan|Feb|...|Dec)[a-z]* (?:\d{2,4}) 可以更灵活地处理日期和年份可能缺失或格式不同的情况。

构建一个能覆盖所有可能日期变体的正则表达式是一个迭代过程。你需要不断测试和调整模式,以处理像“23rd October”中的“rd”序数词、月份的单/双位数、年份的两位/四位数等各种边界情况。

总结 📝

本节课中,我们一起学习了正则表达式。我们了解了它们是什么、为何有用,并详细介绍了核心的元字符及其含义。通过从推文中提取提及和话题标签、查找元音辅音,以及尝试匹配多种日期格式的实践,我们掌握了如何构建正则表达式来识别文本中任何你想要的特定模式字符串。

在接下来的视频中,我们将学习如何处理其他类型的文本变体。

4:带命名组的Pandas正则表达式 🐍

在本节课中,我们将学习如何在Pandas中处理文本数据,特别是使用正则表达式和字符串方法。我们将从基础操作开始,逐步深入到使用命名捕获组进行复杂的数据提取。

首先,让我们查看我们的数据框。它目前只有一个名为text的列,每个条目都是一个包含星期几和时间的字符串。

使用Pandas字符串方法

通过str属性,我们可以访问一系列字符串处理方法,这些方法可以轻松地对Series中的每个元素进行操作。

以下是几个基础字符串方法的示例:

  • str.len():计算每个字符串的字符数。
  • str.split():按空格分割每个字符串。
  • str.contains():检查字符串是否包含特定模式。
  • str.count():统计模式在每个字符串中出现的次数。
  • str.findall():查找字符串中所有匹配模式的子串。

例如,使用str.findall()可以找出每个字符串中所有的数字。

使用正则表达式捕获组

正则表达式的捕获组允许我们提取感兴趣的模式。假设我们想从每个字符串中提取小时和分钟。

通过用括号()捕获这些不同的组,str.findall()能够将这些组返回给我们。

# 示例:提取小时和分钟
pattern = r'(\d{1,2}):(\d{2})'
df['text'].str.findall(pattern)

使用str.replace()进行替换

str.replace()方法可用于替换字符串中的模式。例如,将所有以“day”结尾的星期几替换为三个问号。

df['text'].str.replace(r'\w+day', '???')

如果想基于原始内容进行更复杂的替换,可以结合使用str.replace()和Lambda表达式。例如,将完整的星期几名称替换为其三字母缩写。

df['text'].str.replace(r'(\w+day)', lambda m: m.group(0)[:3].upper())

使用str.extract()创建新列

str.extract()方法允许我们使用提取的组快速创建新的数据列。例如,使用之前提取小时和分钟的模式,我们可以得到一个包含两列(小时和分钟)的新数据框。

df[['hour', 'minute']] = df['text'].str.extract(r'(\d{1,2}):(\d{2})')

请注意,str.extract()只提取模式第一次匹配的组。要获取所有匹配项,应使用str.extractall()

使用命名捕获组

命名捕获组可以使提取的数据更具可读性。我们可以在组的开头括号后使用?P<name>语法为组命名。

extractall()方法将使用这些组名作为返回数据框的列名。

# 示例:使用命名组提取时间信息
pattern = r'(?P<time>(?P<hour>\d{1,2}):(?P<minute>\d{2}) (?P<period>[AP]M))'
df['text'].str.extractall(pattern)

在上面的模式中,我们为整个时间、小时、分钟和上午/下午时段分别定义了命名组。

总结

本节课中,我们一起学习了Pandas中处理文本数据的核心方法。我们从基础的字符串操作开始,如计算长度和分割,然后学习了如何使用正则表达式进行模式匹配、提取和替换。最后,我们探讨了强大的str.extract()str.extractall()方法,以及如何使用命名捕获组来清晰地组织提取的数据。

Pandas提供了许多额外的文本数据处理方法,建议查阅Pandas官方文档中关于“Working with Text Data”的部分以获取更多信息。通过组合使用这些方法,你将能够利用Pandas进行非常强大的文本处理。

5:国际化与非ASCII字符处理 🌐

在本节课中,我们将要学习如何处理国际化文本和非ASCII字符。随着互联网的发展,英语已不再是网络上的主导语言,处理多种语言和字符集变得至关重要。我们将探讨字符编码的历史、Unicode标准,以及如何在Python中正确处理这些字符。

字符编码的演变

上一节我们介绍了数据科学中文本处理的重要性,本节中我们来看看字符编码是如何发展的。

世界由多种语言构成。我们几乎总是在谈论英语,但英语在互联网上正逐渐成为少数语言。英语通常使用ASCII编码。ASCII代表美国信息交换标准代码,这是一种捕获所有字符的编码方案。它有7位长,因此有128个有效字符或有效代码。在十六进制形式中,其范围从00到7F。这意味着它从8位中取出这7位,并使用8位编码的低半部分。

这种模型和编码方案自计算开始以来已经使用了相当长的时间。它包括大写和小写字母、所有10个数字、所有标点符号。常见的符号如括号、百分号、美元符号和井号。它还有一些控制字符,用于描述行尾、制表符或其他控制字符,例如段落结束与行结束的区别。对于英文打字来说,它工作得相对较好。

之所以说相对,是因为即使在英语中,它也不能真正捕获所有内容。让我们思考一下变音符号。例如,“resume”和“résumé”。如果忽略变音符号,它们是同一个单词。它们有相同的六个字符R E S U M E,但“résumé”中的E是不同的。这在ASCII编码方案中并未真正编码。这并非唯一例子,“naïve”中的“i”和上面的分音符号是另一个例子。咖啡馆“café”中带变音符号的“e”也是如此。

这在其他语言中相当常见。正如我们所看到的,英语不一定是多数语言,因此我们也必须对这些变化更加敏感。例如,城市名称如魁北克或苏黎世。或者组织名称,如国际足球联合会或FIFA,这是领先的足球协会。

但还有其他语言具有完全不同的编码方案和完全不同的字符,如中文、印地语、希腊语或俄语。此外,还有音乐语言,音乐符号也很重要,特别是如果你使用数字形式创作音乐。你需要以某种方式编码音乐符号。然后,还有非常著名的表情符号,如笑脸。事实上,现在有更多可用的表情符号,所有这些都需要以某种方式编码。

因此,过去对英语来说足够(勉强足够)的编码,在包含变音符号和多种可用语言时,绝对不够。实际上,有许多不同的书写文字需要编码。

以下是不同书写文字在全球使用情况的概述:

  • 拉丁文字(英语基础)占数据的36%,约26亿人使用。36%的人使用基于拉丁语的语言,包括意大利语、法语等(如果忽略变音符号)。
  • 中文有完全不同的书写文字。
  • 梵文是许多印度语言的基础。
  • 阿拉伯文。
  • 西里尔文。
  • 泰米尔文,这是印度南部的另一种印度语言。

这张地图展示了不同文字在世界各地的使用情况。

字符编码标准的发展

上一节我们了解了全球文字的多样性,本节中我们来看看为在计算方案中编码它们而发展出的多种字符编码。

现在已发展出许多字符编码,以便在计算方案中编码它们。因此,你有IBM EBCDIC,这是一种8位编码。你有Latin-1编码,它与ASCII编码略有不同。你有各个国家特定的标准,如JIS(日本工业标准)和CCII(中文信息交换码),就像ASCII一样。你有扩展的Unicode。还有许多其他国家标准。

但后来需要将其标准化并整合在一起,这就是Unicode和UTF-8编码所做的。正如你所见,将页面转换为UTF-8的兴趣很大。纯ASCII编码在过去10到12年中显著且持续下降。而UTF-8在2004年和2006年之前并不那么流行,之后急剧上升,成为目前网页上最常用的编码。

什么是Unicode?

上一节我们提到了Unicode,本节中我们来详细了解一下它。

Unicode是编码和表示文本的行业标准。它包含来自30多种文字和符号集的超过128,000个字符。当我说符号集时,我包括例如希腊符号,或一副纸牌中四种花色的符号等。它可以使用不同的字符编码实现,但UTF-8是其中之一,也是最常见的一种。

以下是Unicode的一些关键编码实现:

  • UTF-8:一种可扩展的编码集,从1字节到4字节。
  • UTF-16:使用1个或2个16位代码。
  • UTF-32:使用32位编码。

UTF-8是8位集,8位是一个字节,因此最小为1字节,然后最多到4字节。UTF-16是16位集,即一个或两个这样的集。UTF-32是32位编码。因此,尽管所有这些编码都使用最多32位,但UTF-8对所有字符使用一种大的32位编码。

深入理解UTF-8

上一节我们介绍了Unicode的多种编码方式,本节我们重点看看最常用的UTF-8。

UTF-8代表Unicode转换格式,8位。以区别于16位和32位的UTF格式。它具有可变长度编码,从一字节到四字节。它还与ASCII向后兼容。例如,所有7位代码并使用前导0的ASCII代码在UTF-8中是相同的代码。因此,所有使用ASCII编码的字符在UTF-8中使用一个字节的信息,并且使用与ASCII相同的字节。

但由于UTF-8可以扩展到四字节,它有许多其他方案来实现这一点并不断扩展。UTF-8是网络的主导字符编码。事实上,它在Python 3中默认处理得很好。如果你使用的是Python 2,则必须在Python脚本开头添加此类语句,以告诉解释器你使用的编码是UTF-8。

Python 2与Python 3中的处理示例

上一节我们了解了UTF-8的原理,本节中我们通过一个具体例子来看看它在Python中的不同表现。

让我们看一个例子。让我们看看单词“résumé”在Python 3和Python 2中的情况。如果你以文本1的形式输入“résumé”并查找该单词的长度。在Python 3中,你会看到它是6个字符长。而在Python 2中,你得到8。为什么会这样?

让我们看看文本本身,你会发现对于Python 3,它返回单词“résumé”。但在Python 2中,如果你打印它,你会看到这些特殊的十六进制代码。因此,变音符号E的位置有十六进制代码C3 A9。或者,在单词末尾又有C3 A9。因此你会知道,这两个符号C3A9一起代表了变音符号E。在Python 2中,它被表示为两个独立的字节。

明确地说,如果你想输出所有这些字符,你会看到在Python 3中六个字符很好地显示出来,而在Python 2中你会得到这8个独立的字符,所以C3是一个字符,A9是一个字符,因为每个是一字节。

那么,你该怎么做呢?如何在Python 2中处理UTF-8字符串?你将在编写“résumé”之前使用“u”。这样就会将长度给出为6。它会以某种方式告诉解释器你有一个Unicode字符串,一个UTF-8字符串。如果你实际输出单个字符,你会看到你得到R,然后得到\xe9,然后再次得到这些字符,那个十六进制代码e9是变音符号E的Unicode等效表示。

总结

本节课中我们一起学习了文本处理中的国际化问题。核心概念是文本中存在大量多样性,尤其是你将看到的文本。因此,对我们来说,认识到这一点以及计算机系统和编码能够捕获这一点非常重要。

ASCII和其他字符编码早期被广泛使用,但由于缺乏标准化,UTF-8编码变得流行起来,现在几乎被独家使用,并且是最流行的编码集。我们看到在Python 3中它是默认处理的,因此你不需要做任何特定的事情,而对于Python 2,你可能需要做一些事情来让解释器知道你正在使用UTF-8编码。

6:自然语言处理基础

在本节课中,我们将要学习自然语言处理的基础知识,并探讨它如何与我们一直在讨论的Python文本挖掘相关联。

什么是自然语言?🤔

自然语言是指人类在日常交流中使用的任何语言。这与人工语言或计算机语言(如Python)形成对比。例如,英语、中文、印地语、俄语或西班牙语都是自然语言。

根据这个定义,我们在短信息或推文中使用的语言也属于自然语言。

什么是自然语言处理?⚙️

自然语言处理是指对自然语言进行的任何计算或操作,旨在获取关于词语含义和句子结构的洞察。

当我们审视自然语言时,需要考虑它们是在不断演变的。以下是语言演变的一些表现:

以下是语言演变的一些具体表现:

  • 新词增加:例如 selfie(自拍)或 photo bomb(抢镜)。
  • 旧词过时:例如 thou shalt(汝应)这类词汇已很少使用。
  • 词义变化:有些词的古今含义可能完全相反。例如,learn 在古英语中意为“教导”。
  • 语法规则变化:例如,在古英语中,动词通常位于句末,而非现代英语常见的句中位置。

自然语言处理任务 📋

当我们谈论NLP任务时,具体指什么呢?任务可以非常简单,也可以非常复杂。

以下是NLP的一些常见任务:

  • 基础任务:例如统计词数、计算词频或在语料库中查找独特词汇。
  • 进阶任务:在此基础上,可以寻找句子边界或进行词性标注。
  • 句法分析:解析句子结构,尝试理解更复杂的语法结构,并判断它们是否适用于特定句子。
  • 语义角色识别:识别词语在句子中扮演的语义角色。例如,在句子 Mary loves John 中,Mary 是主语,John 是宾语,love 是连接两者的动词。

更复杂的NLP任务 🧩

除了上述任务,NLP还包括一些更复杂的处理。

以下是其他一些NLP任务:

  • 命名实体识别:识别句子中的实体。在我们之前的例子 Mary loves John 中,MaryJohn 就是两个实体(人物)。
  • 指代消解:确定代词所指代的实体。例如,在句子 John said he is happy 中,确定 he 指代的是 John

在自由文本上可以执行的任务还有很多。挑战在于如何高效地完成这些任务,以及它们如何应用于整体的文本挖掘。我们将在接下来的视频中看到其中一些应用。

总结 📝

本节课中,我们一起学习了自然语言处理的基础概念。我们了解了什么是自然语言及其演变特性,并介绍了从基础的词频统计到复杂的命名实体识别和指代消解等一系列NLP任务。这些任务是进行有效文本挖掘的基础,为我们后续深入学习Python中的文本处理技术做好了准备。

7:使用NLTK进行基础NLP任务

在本节课中,我们将学习如何使用NLTK(自然语言工具包)执行基础的NLP(自然语言处理)任务。我们将涵盖NLTK的安装、文本语料库的访问、词汇统计、词频分析、以及文本预处理中的关键步骤,包括归一化、词干提取、词形还原、分词和句子分割。

概述

NLTK是一个开源的Python库,广泛用于自然语言处理。它支持大多数NLP任务,并提供了对众多文本语料库的访问。本节将引导你完成NLTK的基本设置,并演示如何利用它进行文本分析。

安装与设置NLTK

首先,我们需要导入NLTK库并下载其文本语料库。

import nltk
nltk.download()

下载完成后,我们可以导入预置的书籍语料库。

from nltk.book import *

执行此命令将显示九个可用的文本语料库,例如:

  • text1 代表《白鲸记》(Moby Dick)。
  • text2 是《理智与情感》(Sense and Sensibility)。
  • text7 是《华尔街日报》语料库。
  • text5text8 包含聊天和个人广告语料。

词汇统计与词频分析

上一节我们导入了语料库,本节中我们来看看如何分析文本中的词汇。

计算词汇量

我们可以查看单个句子和整个语料库的词汇数量。例如,text7(《华尔街日报》)中的一个句子sent7包含18个词元(token)。而整个text7语料库则包含100,676个词元。

然而,并非所有词元都是唯一的。通过计算唯一词的数量,我们可以得到语料库的实际词汇量。

len(set(text7))

对于text7,唯一词的数量是12,408,这意味着这个10万词的语料库中只有约1.2万个不同的单词。

分析词频分布

要分析单词的出现频率,我们可以使用FreqDist函数。

fdist = FreqDist(text7)

创建频率分布对象fdist后,我们可以进行多种查询:

  • len(fdist) 返回唯一词的数量。
  • list(fdist.keys())[:10] 返回前10个唯一词。
  • fdist[‘word’] 返回特定单词(如’for’)的出现次数。

有时,我们希望找到既常见又有一定长度的“实义词”。以下是筛选长度至少为5个字符且出现次数超过100次的单词的方法:

vocab1 = fdist.keys()
frequent_words = [w for w in vocab1 if len(w) >= 5 and fdist[w] > 100]

设置单词长度限制是为了过滤掉像“the”、“,”、“.”这类非常高频但信息量较少的词。

文本归一化:词干提取与词形还原

在统计了词汇之后,我们常常需要将不同形式的单词归一化,以便进行更准确的分析。这主要涉及词干提取和词形还原。

归一化第一步:大小写转换

最基本的归一化是将所有文本转换为小写,以避免因大小写不同而将同一个词视为两个不同的词。

input1 = “List listed lists listing listings”
words1 = input1.lower().split()

词干提取

词干提取是通过去除常见的后缀(如 -ed, -ing, -s),将单词还原到其词根或基本形式。NLTK提供了多种词干提取器,例如波特词干提取器。

from nltk.stem.porter import PorterStemmer
porter = PorterStemmer()
stemmed = [porter.stem(t) for t in words1]
# 结果:所有单词都可能被提取为 ‘list’

需要注意的是,开发者需要决定是否进行词干提取,因为像“list”(列表)和“listing”(挂牌)这样的词在含义上可能有重要区别。

词形还原

词形还原是词干提取的一个变体,其目标是返回一个字典中存在的、有意义的词元(lemma)。例如,使用NLTK的WordNet词形还原器:

from nltk.stem import WordNetLemmatizer
lemmatizer = WordNetLemmatizer()
udhr = nltk.corpus.udhr.words(‘English-Latin1’)
lemmatized = [lemmatizer.lemmatize(word) for word in udhr[:20]]

词形还原器会将“rights”还原为“right”,但会保留“Rights”(首字母大写)不变。通常,结合小写转换和词形还原能获得更好的效果。

分词与句子分割

在进行了词汇层面的处理后,我们需要回到更基础的步骤:如何将一整段文本切分成有意义的单元。

分词

简单的空格分割对于自然语言来说通常不够精确。例如,句子“Children shouldn’t drink a sugary drink before bed.”用空格分割会得到“shouldn’t”和“bed.”这样的词元,其中包含了标点符号。

NLTK内置的分词器可以更智能地处理这种情况:

text11 = “Children shouldn’t drink a sugary drink before bed.”
tokens = nltk.word_tokenize(text11)

NLTK分词器会将“shouldn’t”正确地分割为“should”和“n’t”(表示否定),并将句号“.”分离为独立的词元。这对于后续检测否定等语义信息非常重要。

句子分割

确定句子边界是另一个关键任务。句子可能以句号、问号或感叹号结束,但并非所有句号都表示句子结束(例如“U.S.”或“$2.99”中的句号)。

NLTK的句子分割器可以有效地解决这个问题:

text12 = “This is the first sentence. A gallon of milk in the U.S. costs $2.99. Is this the third sentence? Yes, it is!”
sentences = nltk.sent_tokenize(text12)

该分割器能够正确识别出上述文本中的四个句子,忽略“U.S.”和“$2.99”中的句号。

总结

本节课中我们一起学习了NLTK工具包的基础应用。我们了解了如何安装NLTK并访问其丰富的文本语料库。接着,我们探索了如何对文本进行词汇统计和词频分析。然后,我们深入研究了文本预处理的核心步骤:归一化(包括小写转换、词干提取和词形还原),这些步骤有助于将文本数据标准化。最后,我们学习了如何使用NLTK进行准确的分词和句子分割,这是任何NLP流水线的基础。NLTK提供了强大的工具来处理这些非平凡的任务,使我们能够更有效地进行后续的自然语言处理分析。

8:使用NLTK进行高级NLP任务

在本节课中,我们将从基础的NLP任务过渡到使用NLTK进行高级NLP任务。我们将重点学习词性标注和句法分析,理解它们在自然语言处理中的重要性,并探讨其中存在的挑战。

词性标注 🏷️

上一节我们介绍了基础的文本处理任务,如分词和词干提取。本节中,我们来看看如何识别句子中每个单词的词性。

词性是指单词的语法类别,例如名词、动词、形容词等。在NLTK中,词性标签远不止这些基础类别,还包括连词、基数词、限定词、介词、情态动词、代词、副词、符号等多种类型。动词本身也分为多种时态和形式。

要查看NLTK中某个标签的具体含义,可以使用 nltk.help.upenn_tagset() 命令。例如,输入 MD 会显示它代表情态助动词,并列出如 cancannotcouldmaymightshallshouldwillwould 等单词。

以下是进行词性标注的步骤:

  1. 首先,需要将句子分割成单词(分词)。
  2. 然后,对分词后的列表应用NLTK的词性标注器。

例如,对于句子 “Children shouldn’t drink a sugary drink before bed.”:

import nltk
sentence = "Children shouldn't drink a sugary drink before bed."
tokens = nltk.word_tokenize(sentence)
# tokens: ['Children', 'should', "n't", 'drink', 'a', 'sugary', 'drink', 'before', 'bed', '.']

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/umich-app-ds-py/img/5db6e720fbd92617abaf0be1a03cbd39_5.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/umich-app-ds-py/img/5db6e720fbd92617abaf0be1a03cbd39_6.png)

pos_tags = nltk.pos_tag(tokens)
# pos_tags: [('Children', 'NNS'), ('should', 'MD'), ("n't", 'RB'), ('drink', 'VB'), ('a', 'DT'), ('sugary', 'JJ'), ('drink', 'NN'), ('before', 'IN'), ('bed', 'NN'), ('.', '.')]

标注结果显示:Children 是复数名词(NNS),should 是情态动词(MD),n’t 是副词(RB),drink 是动词(VB),a 是限定词(DT),等等。

进行词性标注的原因在于,它可以将具有相似语法功能的单词(如所有名词、所有情态动词)归为一类。这在特征工程或特征提取中非常有用,因为你无需单独处理每个单词,而是可以处理整个类别。

词性标注的歧义性 🤔

现在我们已经知道如何进行词性标注,接下来应该讨论词性标注中存在的歧义问题。事实上,歧义在英语中非常常见。

以这个句子为例:“Visiting aunts can be a nuisance.” 它有两种可能的含义:

  1. 去拜访阿姨可能是一件麻烦事。(“Visiting” 作为动词的现在分词)
  2. 来访的阿姨们可能是一件麻烦事。(“Visiting” 作为形容词)

如果我们对这个句子进行分词和词性标注:

tokens = nltk.word_tokenize("Visiting aunts can be a nuisance.")
pos_tags = nltk.pos_tag(tokens)
# 结果可能显示:('Visiting', 'VBG'), ('aunts', 'NNS'), ...

这个结果将 “Visiting” 标注为动名词(VBG),意味着第一种解释(“去拜访”)被选中。这是因为在大型语料库中,“visiting” 更常被用作动词的进行时形式,而不是形容词。NLTK的词性标注器基于统计概率,会选择在给定上下文中更常见的标签。然而,第二种解释在语法上也是完全有效的,这体现了自然语言的歧义性。

句法分析 🌳

既然我们已经了解了如何找出句子的词性,现在让我们来看看句子结构本身,并进行句法分析。

如果遵循定义良好的语法结构,理解句子就会变得容易。例如,对于句子 “Alice loves Bob”,我们不仅想知道哪个是名词、哪个是动词,还想知道它们在这个句子中是如何关联、如何组合成句的。

在NLTK中,我们可以使用上下文无关文法来定义句子结构,并使用解析器进行分析。以下是一个简单的例子:

# 定义文法规则
grammar = nltk.CFG.fromstring("""
    S -> NP VP
    VP -> V NP
    NP -> 'Alice' | 'Bob'
    V -> 'loves'
""")

# 创建解析器
parser = nltk.ChartParser(grammar)

# 解析句子
sentence = ['Alice', 'loves', 'Bob']
for tree in parser.parse(sentence):
    print(tree)
    tree.pretty_print()

这段代码会生成一个句法分析树,显示句子 “Alice loves Bob” 的结构:句子(S)由名词短语(NP “Alice”)和动词短语(VP)组成;动词短语又由动词(V “loves”)和名词短语(NP “Bob”)组成。

句法分析的歧义性 🔄

与词性标注一样,句法分析也存在歧义,即使句子在语法上是正确的。

一个经典的例子是:“I saw the man with a telescope.” 这个句子有两种含义:

  1. 我用望远镜看到了那个人。(“with a telescope” 修饰动词 “saw”)
  2. 我看到了那个拿着望远镜的人。(“with a telescope” 修饰名词 “man”)

这种歧义来源于介词短语 “with a telescope” 在句法树中附着位置的不同。在语法上,动词短语(VP)可以有两种构成方式:

  • 方式一:动词(V) + 名词短语(NP),而名词短语本身可以包含一个介词短语(PP)。
  • 方式二:动词短语(VP) + 介词短语(PP),其中动词短语是动词(V)+名词短语(NP)。

在NLTK中,如果我们定义一个包含这两种可能性的文法,解析器会生成两棵不同的句法树,对应句子的两种不同解释。

使用树库 🌲

我们之前举例使用了简单的自定义文法,但不可能每次都手动创建文法。实际上,生成通用的文法规则本身就是一个需要大量训练数据和人工投入的学习任务。为此,人们创建了“树库”——一个大型的已解析句法树集合。

NLTK提供了对树库的访问,例如来自《华尔街日报》的树库数据:

from nltk.corpus import treebank
first_sentence = treebank.parsed_sents()[0]
print(first_sentence)

这将输出树库中第一个句子的句法分析树,例如:“Pierre Vinken, 61 years old, will join the board as a nonexecutive director Nov. 29.” 这个句子已经按照树库中的结构进行了分析。

语言处理的复杂性 🧩

最后需要指出,词性标注和句法分析之外,语言处理还存在更多复杂性。

例如,有些句子看似符合语法,但解析困难或毫无意义:

  • 非常规用法:如 “The old man the boat.” 乍一看,“the old man” 像是一个名词短语,但实际上 “man” 在这里是动词(意为“操纵”)。标准的词性标注器可能会错误地将 “man” 标注为名词。
  • 无意义的句子:如 “Colorless green ideas sleep furiously.” 这个句子在语法结构上似乎正确,但语义上毫无意义。即使词性标注结果正确(形容词+名词+动词+副词),它也不传达任何实际含义。

这表明,语言有许多层次的含义和复杂性,仅靠词性标签和句法树目前还无法完全解决。

总结 📝

本节课中我们一起学习了使用NLTK进行高级NLP任务。

  • 词性标注:我们学习了如何为句子中的每个单词分配语法类别(如名词、动词),这有助于理解单词的类型和功能,对特征工程非常有用。
  • 句法分析:我们探讨了如何解析句子的语法结构,生成句法树以理解单词之间的组合关系。
  • 任务难点:我们认识到这两项任务都具有挑战性,语言中的歧义性(如词性歧义、句法附着歧义)进一步增加了难度。
  • 工具与数据:NLTK不仅提供了执行这些任务的工具,还提供了如树库这样的数据资源,可用于训练更复杂的模型。

这些高级NLP任务为我们从文本中提取更深层次的信息奠定了基础。在接下来的模块中,我们将更详细地探讨如何训练这些模型,以及如何构建用于此类任务的监督学习方法。

9:应用实例:拼写检查器

在本节课中,我们将学习文本处理的一个具体应用:拼写检查任务。我们将探讨如何识别和纠正拼写错误的单词,并介绍两种衡量单词相似度的核心方法。

概述

拼写检查的任务是,当用户输入一个拼写错误的单词时,如何找到该单词的正确拼写并进行替换。

一种常见的方法是,在词典中查找与错误单词拼写相似的合法单词。要实现这一点,需要满足两个条件:首先,需要一个合法单词的词典;其次,需要一种衡量单词间相似度的方法。

构建词典

第一个要求是拥有一个合法单词的词典。NLTK库再次为我们提供了帮助。它包含一个名为words的语料库,其中收录了英语单词。通过导入NLTK的words包,我们可以获得一个英语词典。

from nltk.corpus import words
word_list = words.words()

衡量单词相似度

第二个要求是衡量单词相似度的方法。这稍微复杂一些。我们可以使用两个字符串之间的编辑距离

编辑距离是指将字符串A转换为字符串B所需的最小操作次数。操作通常包括插入、删除或替换一个字符。在本场景中,字符串A是拼写错误的单词,字符串B是候选的正确单词。所需的操作次数越少,两个单词就越相似。

莱文斯坦距离

一种具体的算法是莱文斯坦距离。它允许三种操作:插入、删除或替换一个字符。

例如,将单词“water”转换为“cooler”。一种直观的方法是逐个字符替换(W->C, A->O, T->O, E->L, R->R),然后插入一个‘L’,总共需要6次编辑(5次替换+1次插入)。

然而,莱文斯坦距离算法的优势在于寻找最优对齐方式以最小化编辑次数。我们可以发现“ER”是共有的后缀,因此只需将“WAT”替换为“COO”,再插入一个‘L’。这样,编辑距离就减少到了4。

公式: 莱文斯坦距离 LD(A, B) 是将字符串A转换为字符串B所需的最少单字符编辑(插入、删除、替换)次数。

N元语法方法

另一种衡量相似度的方法是使用N元语法。在字符层面,N元语法是指单词中长度为n的连续字符序列。

例如,对于单词“pierce”,其字符级别的二元语法(bigrams)为:{‘pi’, ‘ie’, ‘er’, ‘rc’, ‘ce’}

对于一个拼写错误的单词“peererce”,其二元语法为:{‘pe’, ‘ee’, ‘er’, ‘re’, ‘rc’, ‘ce’}

雅卡尔相似度

我们可以通过计算两个单词N元语法集合的雅卡尔相似度来衡量它们的相似性。雅卡尔系数通过计算两个集合的交集大小除以并集大小得到。

公式J(A, B) = |A ∩ B| / |A ∪ B|

在上面的例子中,两个集合的交集为 {‘er’, ‘rc’, ‘ce’},大小为3。并集大小为7。因此,雅卡尔相似度为 3/7

我们可以利用这个度量,在词典中寻找与错误单词具有最高雅卡尔相似度的合法单词,并将其作为纠正后的拼写。

应用与总结

以下是本周作业中你将探索的两个核心任务。

  1. 构建简单拼写检查器:结合NLTK的词典和编辑距离或N元语法相似度算法,可以构建一个基础的拼写检查器。
  2. 查找近似重复段落:N元语法的概念不仅适用于字符,也适用于单词。通过计算文档中单词级别的N元语法(如二元组、三元组)及其相似度,可以用于检测近乎相同的文本段落。

事实上,N元语法是许多自然语言处理任务的基础,例如识别词缀、进行后缀匹配,以及在先进的机器学习和深度学习模型中构建字符级嵌入表示。因此,理解和掌握这个概念非常有益。

本节课中,我们一起学习了拼写检查的基本原理,并介绍了两种关键的文本相似度度量方法:基于编辑操作的莱文斯坦距离和基于集合比较的N元语法雅卡尔相似度。这些是构建基础文本处理工具的重要组件。

10:文本分类技术 🧠

在本节课中,我们将要学习文本分类,也称为文本的监督学习。我们将从理解什么是分类开始,探讨其应用场景,并介绍构建分类模型所需的关键概念和步骤。

什么是分类? 🏷️

分类的任务是为一组给定的输入分配正确的类别标签。在之前的例子中,我们看到了三个类别:肾脏病学、神经病学和足病学。我们的目标是根据文本内容,将新的医学文档归入正确的类别。

文本分类的应用实例 📰

以下是文本分类在现实世界中的一些常见应用:

  • 新闻分类:根据新闻文章的内容,将其归类为政治、体育或科技等版面。
  • 垃圾邮件过滤:分析电子邮件内容,判断其是否为垃圾邮件,并决定是否将其放入垃圾邮件文件夹。
  • 情感分析:阅读一篇电影评论,判断其表达的是正面还是负面的情感。
  • 拼写纠正:在单词级别进行判断。例如,根据上下文判断“weather”(天气)和“whether”(是否)哪个拼写正确;或根据目标读者判断“color”(美式拼写)和“colour”(英式拼写)哪个更合适。

所有这些任务都属于监督学习的范畴。

监督学习流程 🔄

监督学习之所以称为“监督”,是因为机器像人类一样,从带有标签的过往实例中学习。其流程主要分为两个阶段:

  1. 训练阶段:使用带有标签的数据集来构建模型。
  2. 推理阶段:将训练好的模型应用于新的、无标签的数据,以预测其标签。

数据集术语 📊

在监督学习中,我们会遇到几种不同类型的数据集:

  • 带标签数据集:每个数据实例都包含特征(X)和对应的正确标签(y)。
  • 无标签数据集:数据实例只有特征(X),没有标签(y),用于推理预测。
  • 训练集:从带标签数据中划分出来,专门用于训练模型、学习参数的部分。
  • 验证集/留存集:在训练阶段用于评估模型性能、调整超参数的数据集,确保模型不会过度拟合训练数据。
  • 测试集:完全独立、在训练和调参过程中从未见过的数据集,用于最终评估模型的泛化能力。

分类问题的类型 📈

根据类别数量的不同,分类问题可以分为几种类型:

  • 二分类:只有两个可能的类别。例如:垃圾邮件/非垃圾邮件、正面/负面。
  • 多分类:类别数量大于两个。例如:将新闻分为政治、体育、科技等多个类别。
  • 多标签分类:一个数据实例可以同时属于多个类别。例如,一篇新闻可能同时被标记为“政治”和“经济”。

在本课程中,我们主要关注二分类和多分类问题。

构建分类模型的关键问题 ❓

在构建一个监督学习模型时,无论是训练阶段还是推理阶段,我们都需要回答一些核心问题。

上一节我们介绍了分类的基本概念,本节中我们来看看构建模型时需要解决的具体问题。

训练阶段需要回答的问题

以下是训练阶段需要明确的核心问题:

  1. 特征表示:如何将输入数据(如文本)转化为模型可以理解的特征(X)?例如,对于垃圾邮件检测,特征可能包括是否包含“尼日利亚”、“中奖”、“汇款”等特定词汇。
  2. 模型与算法:使用哪种分类模型或算法?例如,逻辑回归、支持向量机或神经网络。
  3. 模型参数:根据所选模型,需要学习哪些具体的参数?例如,每个特征对应的权重。

推理阶段需要回答的问题

在推理阶段,我们关注的是模型的性能:

  1. 性能评估:使用哪些指标来衡量模型的好坏?例如,准确率、精确率、召回率或F1分数。
  2. 模型验证:如何知道我们构建的模型是优秀的?这需要通过独立的测试集进行最终验证。

总结 📝

本节课中我们一起学习了文本分类的基础知识。我们了解到文本分类是一种监督学习任务,其目标是为文本数据分配预定义的类别标签。我们探讨了分类的应用场景,如新闻分类和垃圾邮件过滤,并介绍了监督学习的两个核心阶段:训练和推理。此外,我们还区分了二分类、多分类和多标签分类等不同类型。最后,我们明确了在构建一个分类模型时,无论是特征表示、算法选择还是性能评估,都需要系统地回答一系列关键问题。在接下来的课程中,我们将逐一深入探讨这些问题。

11:文本特征识别

在本视频中,我们将回答监督学习场景中需要考虑的三个问题中的第一个。这个问题就是特征识别。你如何从文本中识别特征。

为什么文本数据如此独特?

在监督学习场景中,文本数据带来了非常独特的挑战。你所需的所有信息或案例中的所有信息都包含在文本中。但文本是一个非常宽泛的概念。你可以用不同的方式解析文本文档。特征可以从文本中以不同的粒度提取。

文本特征类型示例

让我们来看一个你将获得的文本特征类型的例子。

文本的基本构成单位是单词集合。因此,这是迄今为止最常见的特征类别,当你添加它们时,会带来大量的特征。例如,英语中大约有40,000个独特的单词。所以,仅看普通英语,你就会有40,000个特征。如果你查看社交媒体或其他类型的数据,你可能会得到更多的特征,因为可能存在独特的单词拼写等等,所有这些都将是单词。

当你获得这么多特征时,这里要开始回答的一个问题是,如何处理常见出现的单词。在某些情况下,它们被称为停用词。像“the”这样的词出现得非常频繁。它是英语中最常见的词。每个文档、每个句子都可能包含“the”这个词。但更普遍地说,对于分类任务,“the”这个词不如其他词重要。例如,如果你谈论政治,并且有“Parliament”这个词,那么“Parliament”这个词比“the”这个词更能决定文档是否属于政治类别。

下一步是归一化。你是否将所有单词转换为小写,以便大写P的“Parliament”和小写p的“parliament”被视为相同或相同的特征?或者我们应该保持原样?“US”大写指的是美国,而如果你将其小写,它将与单词“us”无法区分。所以在某些情况下你想保持原样,在某些情况下你想转换为小写,那么你如何做出这个选择?

还存在词干提取和词形还原的问题。例如,你不希望复数形式成为不同的特征。所以你会想要对它们进行词形还原或词干提取。

以上所有这些仍然关于单词,让我们超越单词。你实际上可以识别单词的特征或特性,例如,大写。

正如我所说,“US”是大写的。“White House”中“W”和“H”大写与“white house”非常不同,对吧?所以大写是识别某些单词及其含义的重要特征。

你可以使用句子中单词的词性。这些可以作为你的特征。例如,如果某个特定单词前面有一个限定词很重要,那么那个单词就成为一个重要特征。一个例子是“weather”和“whether”的例子,回想我们讨论过的拼写纠正问题,你想确定正确的拼写是“whether”(W-H-E-T-H-E-R)还是“weather”(W-E-A-T-H-E-R)。如果你看到一个限定词,比如“the”,在那个词前面,它很可能是“weather”(W-E-A-T-H-E-R)。所以这个词前面的特定词性成为一个非常重要的特征。

你可能还想知道句子的语法结构或解析句子并获取句子解析结构,以查看与特定名词关联的动词是什么,它离关联的名词有多远等等。然后,你可能希望将意义相似的单词分组,用一个特征来表示一组单词。一个例子是“buy”和“purchase”等等。这些都是同义词。你不希望有两个不同的特征,一个用于“buy”,一个用于“purchase”,你可能想把它们归为一组,因为它们意思相同,具有相同的语义。但也可能是其他组,如头衔或尊称,如“Mr.”、“M”、“doctor”、“professor”等。或者数字集合,因为你不想专门为0设置一个特征,为1设置另一个特征等等,所有数字,所以你可能想说,如果它是0到10,000之间的任何数字,我就把它称为“number”。这样你就突然将10,000个特征减少到一个。日期也是如此,如果你能使用正则表达式识别日期,并且做得很好,那么你可能想说,也许所有日期都会被识别并称为一个特征“date”,因为我不想为每一个可能的特定日期学习一个特征,那是一个无限的列表。

其他类型的特征将取决于分类任务。例如,你可能拥有来自单词内部的特征或具有单词序列的特征。一个例子是二元组或三元组。想到“White House”的例子,你想说“White House”作为一个双词结构(二元组)在概念上是一个事物。与“white”和“house”作为两个不同的特征、两个不同的事物相比。你可能还想拥有字符子序列,例如“ING”或“ION”。仅仅通过查看它,你就知道“ING”基本上表示它是一个动词的进行时形式,对吧?所以仅仅查看一个单词中的“ING”就能将其识别为动词。“ION”更可能出现在单词末尾,是某种形式的名词。因此,仅这些字符子序列就可以帮助你识别某些类别,如果这对于你拥有的特定分类任务很重要的话。

如何实现?

我们已经讨论了其中一些特征,我建议你回顾前一周关于自然语言处理和基本NLP任务的讲座,我们在那里已经解决了一些这样的问题。你现在只需要识别它们,并使其作为特征可用于你的分类任务。

我们很快会看到更多特征的例子。

总结

在本节课中,我们一起学习了在监督学习场景下从文本数据中识别特征的核心概念。我们探讨了文本数据的独特性,并详细介绍了多种可能的文本特征类型,包括单词、停用词处理、归一化(如大小写转换)、词干提取与词形还原。我们还超越了单词层面,讨论了利用大写、词性、语法结构、语义分组(如同义词、数字、日期)以及字符子序列(如二元组、三元组)作为特征的方法。理解如何根据具体任务选择和构建这些特征是进行有效文本分类的关键第一步。

12:朴素贝叶斯分类器

在本节课中,我们将要学习一种名为朴素贝叶斯(Naive Bayes)的分类算法。这是一种基于概率的模型,特别适用于文本分类任务。我们将通过一个具体的例子来理解其核心思想、数学原理以及如何在实际中应用它。

概述:分类文本搜索查询

为了理解朴素贝叶斯分类器,我们首先设定一个场景:对文本搜索查询进行分类。

假设我们有三个类别:娱乐类、计算机科学类和动物学类。我们预先知道,大多数搜索查询都与娱乐相关。这意味着,在没有任何额外信息的情况下,一个查询属于娱乐类的概率最高。

现在,我们收到一个查询词:“Python”。我们的任务是将“Python”分类到上述三个类别之一。这个词本身具有多义性:

  • 如果指“蟒蛇”(蛇),则属于动物学类。
  • 如果指“Python编程语言”,则属于计算机科学类。
  • 如果指“巨蟒剧团”(Monty Python),则属于娱乐类。

仅仅“Python”这个词无法让我们做出准确判断。然而,如果我们知道在搜索查询中,“Python”这个词本身最常与动物学相关,那么即使总体上娱乐类查询更多,在看到“Python”这个词后,该查询属于动物学类的可能性也会变得更高。

如果我们收到另一个查询“Python download”,情况又会发生变化。此时,该查询属于计算机科学类的可能性变得最高。

这个思维过程体现了朴素贝叶斯分类器的核心:我们有一个先验概率模型,它告诉我们每个类别的初始可能性。当我们获得新的信息(例如查询词)时,我们会根据这个新信息来更新每个类别的可能性。

贝叶斯定理:概率更新的数学基础

上一节我们介绍了根据新信息更新概率的想法,本节中我们来看看其背后的数学原理——贝叶斯定理。

我们有以下概念:

  • 先验概率:在获得任何新信息之前,一个查询属于某个类别 y 的概率,记作 P(y)。例如,P(娱乐) 可能很高。
  • 后验概率:在观察到输入数据 x(例如查询词“Python”)之后,该查询属于类别 y 的概率,记作 P(y|x)。这是我们最终想要计算的。
  • 似然度:假设我们知道查询属于类别 y,那么观察到特征 x 的概率,记作 P(x|y)。例如,P(Python|动物学) 表示在动物学类查询中看到“Python”这个词的概率。

贝叶斯定理将这些概念联系起来:

P(y|x) = [P(y) * P(x|y)] / P(x)

其中:

  • P(y|x) 是后验概率。
  • P(y) 是先验概率。
  • P(x|y) 是似然度。
  • P(x) 是证据(观察到 x 的总概率),它是一个归一化常数,确保所有类别的后验概率之和为1。

在我们的例子中,当我们看到查询“Python”时,我们计算:

  • P(娱乐|Python) = [P(娱乐) * P(Python|娱乐)] / P(Python)
  • P(计算机科学|Python) = [P(计算机科学) * P(Python|计算机科学)] / P(Python)
  • P(动物学|Python) = [P(动物学) * P(Python|动物学)] / P(Python)

然后我们比较这三个值,选择概率最高的类别作为预测结果。

朴素贝叶斯分类器的“朴素”假设与最终公式

上一节我们介绍了贝叶斯定理,但在实际分类中,输入 x 通常包含多个特征(例如,一个查询有多个词)。朴素贝叶斯分类器通过一个关键假设来处理多特征情况。

朴素贝叶斯分类器做出一个“朴素”的假设:在给定类别标签 y 的条件下,所有特征(例如单词)之间是相互独立的。这意味着一个单词的出现不影响另一个单词的出现概率。

基于这个假设,包含多个特征 (x1, x2, ..., xn) 的似然度可以简化为每个特征似然度的乘积:

P(x1, x2, ..., xn | y) = P(x1|y) * P(x2|y) * ... * P(xn|y)

将这个简化后的似然度代入贝叶斯公式,并忽略对所有类别都相同的分母 P(x)(因为它不影响比较大小),我们得到朴素贝叶斯分类器的决策规则:

预测的类别 y = argmax_y [ P(y) * ∏_{i=1}^{n} P(xi|y) ]*

这个公式的意思是:对于每个可能的类别 y,计算先验概率 P(y) 与所有特征似然度 P(xi|y) 乘积的乘积。然后,选择使这个乘积最大的类别 y 作为预测结果。

以查询“Python download”为例,我们计算:

  • 对于 y = 动物学P(动物学) * P(Python|动物学) * P(download|动物学)
  • 对于 y = 计算机科学P(计算机科学) * P(Python|计算机科学) * P(download|计算机科学)
  • 对于 y = 娱乐P(娱乐) * P(Python|娱乐) * P(download|娱乐)

即使 P(Python|动物学) 很高,但 P(download|动物学) 极低,且 P(动物学) 本身也较低,它们的乘积可能很小。而计算机科学类在三项概率上都有中等或较高的值,其乘积很可能最大,因此预测为计算机科学类。

模型参数:我们需要学习什么?

现在我们已经了解了朴素贝叶斯如何做决策,本节中我们来看看构成这个模型的具体参数是什么,以及它们有多少个。

朴素贝叶斯模型的参数分为两类:

  1. 先验概率:对于数据集中的每一个类别 y,我们需要知道 P(y)。如果有 |Y| 个类别,就有 |Y| 个先验概率参数。
  2. 似然度(条件概率):对于每一个类别 y 和每一个可能的特征 xi,我们需要知道 P(xi|y)。如果有 |Y| 个类别和 V 个不同的特征(例如词汇表大小),那么就有 |Y| * V 个似然度参数。

参数总数 = |Y| + |Y| * V

练习:如果你有3个类别(|Y|=3)和100个特征(V=100),那么朴素贝叶斯模型有多少个参数?
答案:先验参数:3个。似然度参数:3 * 100 = 300个。总参数:303个。

参数估计:如何从数据中学习?

上一节我们明确了模型参数,本节中我们来看看如何利用训练数据来估计这些参数。方法非常直观:计数。

假设我们有一个已标注类别的训练数据集。

估计先验概率 P(y)
计算属于类别 y 的文档数量,除以文档总数。
P(y) = (属于类别 y 的文档数量) / (文档总数量)

例如,训练集中有4个查询:2个娱乐类,1个计算机科学类,1个动物学类。则 P(娱乐)=0.5, P(计算机科学)=0.25, P(动物学)=0.25

估计似然度 P(xi|y)
对于每个类别 y,我们只关注那些属于该类别的文档。然后计算特征 xi 在这些文档中出现的频率。
P(xi|y) = (特征 xi 在类别 y 的文档中出现的次数) / (类别 y 的文档中所有特征出现的总次数)

例如,为了估计 P(Python|计算机科学),我们查看所有计算机科学类的查询,统计“Python”这个词出现了多少次,然后除以计算机科学类查询中所有单词出现的总次数。

平滑技术:处理零概率问题

在参数估计过程中,我们可能会遇到一个严重问题:零概率问题

如果某个特征 xi 在类别 y 的训练文档中从未出现,那么根据上面的计数方法,P(xi|y) = 0。在朴素贝叶斯公式中,只要有一个特征的似然度为0,整个连乘积就会变成0,这将导致模型无法预测包含该特征的文档属于类别 y,即使其他特征都强烈支持这个类别。

例如,如果训练数据中没有“Monty Python”这类娱乐查询,那么 P(Python|娱乐)=0。任何包含“Python”的查询都会被模型直接排除出娱乐类,这显然是不合理的。

为了解决这个问题,我们使用平滑技术。最常用的一种是拉普拉斯平滑(加一平滑)

拉普拉斯平滑为每个特征的计数都加上一个小的常数(通常是1),并为分母加上特征的总数 V(词汇表大小)作为补偿:

平滑后的 P(xi|y) = (特征 xi 在类别 y 中的出现次数 + 1) / (类别 y 中所有特征出现总次数 + V)

这样,即使一个词在某个类别中从未出现,其概率也不会是0,而是一个很小的正数(1/(总次数+V)),避免了零概率导致的决策失效。

思考:我们需要对先验概率 P(y) 也进行平滑吗?通常不需要,因为除非某个类别在训练集中完全没有样本,否则先验概率不会为0。如果某个类别没有样本,那它本身就不应该出现在分类选项中。

总结与关键要点

本节课中我们一起学习了朴素贝叶斯分类器。

  • 核心思想:它是一种基于贝叶斯定理的概率分类模型,通过结合先验知识和观察到的数据特征来预测类别。
  • “朴素”假设:它假设在给定类别条件下,所有特征相互独立。这个假设虽然通常不成立(尤其在文本中),但极大地简化了计算。
  • 决策规则:预测使 P(y) * ∏ P(xi|y) 最大的类别 y
  • 参数学习:模型的参数(先验概率和似然度)可以通过在标注数据上简单计数来估计。
  • 平滑的重要性:必须使用平滑技术(如拉普拉斯平滑)来处理训练数据中未出现的特征,防止零概率问题。
  • 应用与地位:尽管模型简单且假设“朴素”,但朴素贝叶斯分类器在文本分类任务中通常能提供非常强大的基线性能。它计算高效、易于理解和实现,因此通常是尝试解决文本分类问题时首选的第一模型,用于建立性能基准。

13:朴素贝叶斯变体

在本节课中,我们将要学习朴素贝叶斯分类器的两种经典变体:多项式朴素贝叶斯伯努利朴素贝叶斯。我们将探讨它们背后的核心假设、适用场景以及它们如何处理文本特征。

上一节我们介绍了朴素贝叶斯模型的基本理论和公式。本节中我们来看看模型如何处理特征,特别是文本特征。朴素贝叶斯模型有两种经典变体,它们对特征的理解和建模方式不同。

多项式朴素贝叶斯模型

多项式朴素贝叶斯模型假设数据服从多项式分布。这意味着,在定义一个特定数据实例的特征集合时,我们假设每个特征的出现是相互独立的,并且每个特征可以出现多次。

因此,在多项式分布模型中,计数变得非常重要。每个特征值都是某种形式的计数或加权计数。例如,单词出现次数或TF-IDF权重。

假设你有一段文本(一个文档),你想找出这个模型中使用的所有单词,这被称为词袋模型。如果你只关心单词是否出现,那么每个特征就服从伯努利分布。但如果你认为一个特定单词出现的次数很重要,那么你就需要跟踪每个单词的频率。

例如,对于句子“to be or not to be”,单词“to”出现了两次,单词“be”出现了两次,单词“or”出现了一次。如果你想给予更罕见的单词更高的权重,你可以使用词频-逆文档频率加权。这不仅能体现单词的频率,还能衡量该单词在整个文档集合中的常见程度。

例如,单词“the”非常常见,几乎出现在每个句子和每个文档中,因此它的信息量不大。但像“significant”这样的单词则更具信息量,因为它不会出现在每个文档中。因此,你会希望给予包含“significant”这个词的文档更高的权重,相比于包含“the”的文档。这种变化和加权在多项式朴素贝叶斯模型中是可以实现的。

以下是多项式朴素贝叶斯中特征向量的一个简单示例,其中特征值是单词的计数:

# 示例:文档的特征向量(词频)
# 词汇表: [‘to‘, ‘be‘, ‘or‘, ‘not‘]
document_vector = [2, 2, 1, 1]  # 对应句子 “to be or not to be”

伯努利朴素贝叶斯模型

第二种模型是伯努利朴素贝叶斯模型。这里的假设是数据服从多元伯努利分布,其中每个特征都是一个二元特征,即一个单词是出现还是不出现。

只有关于单词是否出现的信息是重要的并被建模,而该单词出现了多少次则无关紧要。事实上,单词本身是否常见(如“the”)或不常见(如“significant”)在这种二元特征模型中也无关紧要。当你对每个特征都使用这种二元模型时,整个特征集合就服从所谓的多元伯努利模型。

以下是伯努利朴素贝叶斯中特征向量的一个简单示例,其中特征值表示单词是否出现(1表示出现,0表示未出现):

# 示例:文档的特征向量(是否出现)
# 词汇表: [‘to‘, ‘be‘, ‘or‘, ‘not‘]
document_vector = [1, 1, 1, 1]  # 对应句子 “to be or not to be”,所有词至少出现一次

两种变体的比较与应用

这两种是朴素贝叶斯中两种标准的经典变体。你会发现,大多数用于朴素贝叶斯建模的方法和工具都为你提供了这个选项:选择多项式朴素贝叶斯或伯努利朴素贝叶斯。

在文本文档中,使用多项式朴素贝叶斯非常普遍。但在某些情况下,你可能会希望采用伯努利路线,特别是当你想强调频率无关紧要,而仅仅是单词的存在与否更重要时。

以下是两种模型的核心区别总结:

  • 多项式模型:特征值是计数(如词频),关注“出现了多少次”。
  • 伯努利模型:特征是二元的(出现/未出现),只关注“是否出现”。

本节课中我们一起学习了朴素贝叶斯分类器的两种主要变体。我们理解了多项式朴素贝叶斯如何利用特征计数(如词频)进行建模,适用于关注特征频率的场景。同时,我们也探讨了伯努利朴素贝叶斯,它仅将特征视为二元变量(出现与否),适用于特征出现本身比其频率更重要的场合。在实际应用中,根据具体任务和数据特性选择合适的变体至关重要。

14:支持向量机应用

在本节课中,我们将要学习支持向量机(SVM)的基本概念、工作原理及其在分类任务中的应用。我们将从分类任务的基本概念开始,逐步深入到决策边界、线性分类器、最大间隔思想,以及如何将SVM应用于多分类问题。最后,我们会讨论SVM的关键参数和优缺点。

分类任务回顾

上一节我们介绍了分类任务的基本概念。本节中,我们来看看分类任务的具体例子。

分类器可以被视为一个作用于输入数据的函数。例如,一个函数F可以处理医学文本,并决定其类别标签是肾病学、神经学还是足病学。另一个函数可以分析电影评论,并判断其是正面(+1)还是负面(-1)评价。通常,我们会为类别分配数值标签,如+1和-1,这是支持向量机等线性分类器的典型做法。

决策边界

为了理解支持向量机,我们需要先了解决策边界的概念。

决策边界是用于在特征空间中分隔不同类别的表面。在二维空间中,它可能是一条线;在三维空间中,是一个平面;在更高维空间中,则是一个超平面。

以下是选择决策边界形状时需要考虑的因素:

  • 过拟合问题:一个复杂的决策边界(如不规则多边形)可能在训练数据上达到100%的准确率,但在未见过的测试数据上表现很差。这是因为模型过于复杂,学习了训练数据中的噪声和特定细节,而非一般规律。
  • 简单模型原则:相比之下,一个简单的线性边界(如直线)虽然在训练数据上可能犯一些错误,但它更易于学习、评估,并且通常能更好地泛化到新数据上。简单模型往往具有更好的泛化能力。

线性分类器与最大间隔

上一节我们讨论了决策边界的选择。本节中我们来看看如何寻找一个最优的线性边界。

寻找线性边界就是寻找权重向量 w,使得线性函数 f(x) = w · x + b 能够区分不同类别。当 f(x) > 0 时,样本被标记为正类(+1);否则标记为负类(-1)。

然而,对于线性可分的数据,可能存在无数条能够分隔两类的直线。这就引出了一个问题:哪条线是最优的?

支持向量机的核心思想是最大间隔。它寻找的不是任意一条分隔线,而是能够创建最宽“隔离带”的那条线。这条“隔离带”的宽度称为间隔,位于间隔正中间的超平面就是最终的决策边界。

最大间隔超平面的优势在于它对数据中的小扰动或噪声具有更强的鲁棒性。模型的决策依赖于位于间隔边缘的少数关键样本点,这些点被称为支持向量

多类别分类

到目前为止,我们讨论的SVM都是针对二分类问题的。那么如何处理具有多个类别的分类任务呢?

当需要将SVM用于多类别分类时,通常采用以下两种策略之一:

1. 一对多(One-vs-Rest, OVR)
为每个类别训练一个二分类器,将该类别的样本作为正例,所有其他类别的样本作为负例。对于N个类别,需要训练N个分类器。对于一个新样本,所有N个分类器都会进行预测,选择置信度最高(或得分最高)的类别作为最终结果。

2. 一对一(One-vs-One, OvO)
为每一对类别训练一个二分类器。对于N个类别,需要训练 N * (N-1) / 2 个分类器。对一个新样本进行预测时,所有相关的分类器进行“投票”,得票最多的类别即为最终预测结果。

在大多数情况下,一对多策略更常用,因为它需要训练的分类器数量更少。

关键参数

在使用支持向量机时,有几个关键参数需要调整:

  • 正则化参数 C:控制模型对训练数据中个别样本错误的容忍度。
    • C值较大:正则化较弱。模型会尽可能拟合训练数据,对个别样本的错误给予较大惩罚,可能导致过拟合。
    • C值较小:正则化较强。模型允许更多的训练错误,以换取一个更简单、间隔更大的决策边界,可能提高泛化能力。
  • 核函数(Kernel):决定数据被映射到的特征空间的类型。
    • 线性核(linear):寻找线性决策边界。公式为:K(x, z) = x · z
    • 多项式核(poly):寻找多项式决策边界。
    • 径向基函数核(rbf):寻找复杂的非线性决策边界。这是最常用的非线性核。
  • 多分类策略:如上所述,可选择 ‘ovr’(一对多)或 ‘ovo’(一对一)。
  • 类别权重(class_weight):当数据集中各类别样本数量不平衡时(例如80%是垃圾邮件,20%不是),可以给少数类别赋予更高的权重,防止模型忽略它们。

总结与要点

本节课中我们一起学习了支持向量机(SVM)的核心内容。

以下是支持向量机的主要特点:

  • 高准确性:对于像文本这样的高维数据,SVM通常是性能最优秀的分类器之一。
  • 坚实的理论基础:其最大间隔思想源于优化理论,具有很好的数学解释。
  • 仅处理数值特征:SVM基于点积运算,因此要求输入特征是数值型的。分类特征需要先进行编码(如独热编码)。
  • 特征缩放很重要:建议对特征进行归一化(如缩放到[0,1]区间),以防止某些特征因数值范围大而主导模型。
  • 可解释性差:与逻辑回归或决策树不同,SVM学到的模型(特别是使用非线性核时)难以直观解释为什么一个样本被分到某个类别。
  • 实践建议:如果模型的可解释性不是首要需求,SVM(尤其是线性SVM)应该是文本分类等任务中优先尝试的算法之一,其性能通常与朴素贝叶斯相当甚至更优。

总而言之,支持向量机通过寻找最大间隔超平面来构建一个强大且泛化能力好的分类器,是机器学习工具箱中一个非常重要的工具。

15:Python文本分类器构建 🐍

在本节课中,我们将学习如何在Python中实际构建一个文本分类器。我们将介绍两个主要的工具包:Scikit-learn和NLTK,并详细说明如何使用它们来训练和评估分类模型。


理论回顾与工具选择

上一节我们介绍了文本分类的理论基础。本节中,我们来看看如何在Python中实现它。

构建监督式文本分类器有多个工具包可用。Scikit-learn是其中之一。对于已完成本专项课程第三门课的同学,你们已经接触过Scikit-learn。另一个工具包是我们在本课程中见过的NLTK。实际上,NLTK可以与Scikit-learn交互,也支持与其他机器学习工具包(如Weka)的接口。本课程不会涵盖Weka,但鼓励你自行探索。


使用Scikit-learn构建分类器

Scikit-learn是一个开源的机器学习库。它始于2007年的Google编程之夏项目,拥有非常强大的编程接口。与Weka更偏向图形用户界面不同,Scikit-learn在Python中被广泛用作机器学习库。

Scikit-learn预定义了多种分类器算法,可以直接使用。例如,你可以使用朴素贝叶斯分类器。

以下是使用朴素贝叶斯分类器的步骤:

首先,需要从sklearn导入朴素贝叶斯模块。

from sklearn.naive_bayes import MultinomialNB

然后,调用MultinomialNB()来创建你的基础分类器。

classifier = MultinomialNB()

我们之前提到,朴素贝叶斯模型主要有两种训练方式:一种是多项式模型,另一种是伯努利模型。Scikit-learn也提供了伯努利模型,你可以使用from sklearn.naive_bayes import BernoulliNB

定义好基础分类器后,你可以在训练数据上训练它。

classifier.fit(train_data, train_labels)

如果你对此很熟悉,也可以将创建和训练合并为一步。

classifier = MultinomialNB().fit(train_data, train_labels)

模型训练完成后,你可以使用predict函数为新数据集预测标签。

predicted_labels = classifier.predict(test_data)

得到预测标签后,你可以评估分类效果。如果你有带标签的测试数据,可以使用metrics.f1_score等指标。

from sklearn import metrics
f1 = metrics.f1_score(test_labels, predicted_labels, average='micro')

average参数可以指定为‘micro’‘macro’,用于计算F1分数。这些概念在第三门课中已涵盖,此处不再重复,但建议你阅读相关材料以了解不同评估指标和平均方法的含义。


使用Scikit-learn的SVM分类器

Scikit-learn也支持支持向量机分类器。那么如何训练一个SVM呢?其调用方式非常相似。

在这种情况下,你需要从sklearn导入SVM模块。

from sklearn import svm

然后调用svm.SVC()作为分类器。SVC代表支持向量分类器。

classifier = svm.SVC(kernel='linear', C=1.0)

如你所见,需要传递一些参数。对于文本分类模型,通常关注线性分类器,因此设置kernel='linear'。你还可以指定C参数,我们在之前的视频中讨论过,这是软间隔的参数。kernel的默认值是‘rbf’(径向基函数核),C的默认值是1,此时对间隔既不“太硬”也不“太软”。

定义好这个基础分类器后,你可以用与训练朴素贝叶斯相同的方式训练它。

classifier.fit(train_data, train_labels)

预测方式也与上次相同。

predicted_labels = classifier.predict(test_data)

模型选择

现在我们需要简要讨论一下模型选择。

回想一下,监督学习任务有多个阶段,我们之前讨论过。有训练阶段,在推理阶段,你拥有已标注的数据(例如绿色和红色)。你将这部分标注数据分割为训练数据集和保留(或验证)数据集。然后你还有测试数据,它也可能被标注,用于评估模型在未见数据上的表现,但通常测试数据是未标注的。因此,你需要在标注集上训练模型,然后将其应用于未标注的测试集。

你需要使用部分标注数据来评估这些模型的表现,特别是在比较不同模型或调整模型参数时。例如,对于SVM中的C参数,你需要知道什么值是好的。这被称为模型选择问题。在训练过程中,你需要确保有方法来解决它。

有两种方法可以进行模型选择:一种是将部分标注训练数据集单独留出作为保留数据;另一种选择是交叉验证。

对于第一种方法,在Scikit-learn中,你可以这样做:

from sklearn import model_selection
train_data_split, test_data_split, train_labels_split, test_labels_split = model_selection.train_test_split(train_data, train_labels, test_size=0.333)

例如,假设你有15个数据点,你想进行2/3和1/3的分割,那么test_size就是1/3或0.333。这意味着其中10个将成为训练集,5个将成为测试集。你可以打乱训练数据(标注数据),以便正类和负类有随机均匀的分布。你可以选择保留66%在训练集,33%在测试集,或者如果你想,也可以按80/20分割。

当你这样做时,你会将相当一部分训练数据“损失”到测试集中。请记住,在训练模型时,你不能“看到”测试数据。因此,测试数据专门用于调整参数,所以你的训练数据实际上减少到了66%。

另一种模型选择的方法是交叉验证。

以5折交叉验证为例,你将数据分成五部分,这些部分被称为“折”。然后,你基本上训练五次,每次其中四部分在训练集,一部分在测试集。你将训练五个模型。第一次,你在第1到4部分上训练,在第5部分上测试。下一次,你在第2到5部分上训练,在第1部分上测试,依此类推。这样,每个数据点在这五折中都会在测试集中出现一次。然后,你可以平均在这五个测试集上得到的结果,来评估模型在整个数据集上对未见数据的表现。

交叉验证的折数是一个参数,在这个例子中我设为5。当你有大量数据时,使用10折交叉验证相当常见,这样你可以保留90%用于训练,10%作为交叉验证保留数据集。因为你做了10次,你也在多次运行中进行了平均。实际上,多次运行交叉验证以减少结果的方差是相当常见的。

这两种模型选择方法——训练集分割和交叉验证——都非常常用,并且在执行任何模型选择时都至关重要。


使用NLTK构建分类器

现在让我们转到NLTK。如何在我们本课程中已详细见过的自然语言工具包中进行监督式文本分类呢?

NLTK有一些文本分类算法。例如,它有一个朴素贝叶斯分类器。它还有决策树、条件指数模型和最大熵模型等。但真正有趣的是,它有一个叫做WekaClassifierSklearnClassifier的东西,这为NLTK用户提供了一种方式,可以通过他们的Python代码调用底层的Scikit-learn分类器或底层的Weka分类器。

具体来说,如果你使用NLTK中可用的原生朴素贝叶斯分类器,你会这样做:

from nltk.classify import NaiveBayesClassifier
classifier = NaiveBayesClassifier.train(train_set)

请注意,这里没有像Scikit-learn中那样常见的两个独立函数(一个基础模型和一个训练函数)。在这里,你直接使用NaiveBayesClassifier.train来在训练集上训练模型。

你可以使用classify函数进行分类。

label = classifier.classify(unlabeled_instance)

如果是一个实例,你使用classify函数;如果是多个,你可以使用classify_many并给出一组未标注的实例。

你也可以使用nltk.classify.util.accuracy函数来计算学习到的分类器的准确性或性能。

from nltk.classify.util import accuracy
acc = accuracy(classifier, test_set)

你还可以使用其他实用函数,如labels,它告诉你分类器训练过的所有标签。以及像show_most_informative_features这样的功能,它可以给出最重要的几个特征,你可以指定查看前5个或前10个对分类任务最重要或信息量最大的特征。这在朴素贝叶斯分类器中尤其有用,当你想知道哪些特征包含最多信息或对最终分类器最具信息量时。

对于支持向量机,NLTK没有原生函数,但正如我所说,你可以通过NLTK使用Scikit-learn的SVM函数。这里,你会这样做:

from nltk.classify import SklearnClassifier
from sklearn.svm import SVC
classifier = SklearnClassifier(SVC(kernel='linear', C=1.0)).train(train_set)

对于多项式朴素贝叶斯,你不需要传递参数。但对于支持向量机,你需要指定内核,例如,你可以在这个SklearnClassifier函数内部指定。你说你将调用SVC,并传递参数,例如线性内核和C参数也可以在这里指定。然后你说.train(train_set)。其余部分与在Scikit-learn中的做法非常相似,你可以使用classify函数等。


总结

本节课中我们一起学习了在Python中构建文本分类器的核心方法。

主要收获是:Scikit-learn是Python中最常用的机器学习工具包。但NLTK有其自己的朴素贝叶斯实现,并且它提供了与Scikit-learn及其他机器学习工具包(如Weka)交互的方式,使你能够通过NLTK调用这些函数和实现。

通过掌握这两个工具包的基本用法,以及模型选择的关键技术(如交叉验证),你已经具备了构建和评估基本文本分类模型的能力。

16:案例演示:情感分析研究 📊

在本教程中,我们将学习如何使用Scikit-learn库对亚马逊商品评论进行情感分析。我们将从数据清洗开始,探索两种文本向量化方法(词袋模型和TF-IDF),并最终通过引入N-gram特征来提升模型性能。


在提供的Jupyter Notebook中,你可能需要注释掉对给定数据进行采样的代码行。在跟随视频操作前,移除这行代码意味着我们将在全部数据上运行代码,这可能需要一些时间,但结果将与视频中展示的匹配。


数据准备与清洗 🧹

我们将使用亚马逊关于解锁手机的评论数据集。查看数据框的头部,可以看到包含产品名称、品牌、价格、评分、评论文本、认为评论有帮助的人数等列。为简化分析,我们将重点关注rating(评分)和reviews(评论)这两列。

首先,我们对数据框进行一些清理。

以下是数据清洗的步骤:

  1. 删除任何包含缺失值的行。
  2. 移除所有评分等于3的行,我们假设这些是中性评论。
  3. 创建一个新列作为模型的目标变量。任何评分大于3的评论将被编码为1,表示正面评价;否则编码为0,表示非正面评价。

查看正面评价列的平均值,可以发现数据存在类别不平衡问题。接下来,我们使用评论列和正面评价列将数据分割为训练集和测试集。

查看X_train,可以看到这是一个包含超过231,000条评论(文档)的序列。我们需要将这些文本转换为Scikit-learn可以使用的数值表示。


方法一:词袋模型(Bag of Words) 📦

词袋模型是一种简单且常用的文本表示方法,用于机器学习。它忽略文本结构,只统计每个单词出现的频率。

CountVectorizer允许我们使用词袋模型,将文本文档集合转换为词条计数的矩阵。

首先,我们实例化CountVectorizer并将其拟合到训练数据上。拟合过程包括对训练数据进行分词和构建词汇表。

from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer()
vectorizer.fit(X_train)

拟合CountVectorizer会通过查找由至少两个字母或数字组成的字符序列(以单词边界分隔)来对每个文档进行分词,将所有字符转换为小写,并使用这些词条构建词汇表。我们可以使用get_feature_names_out()方法获取词汇表。这个词汇表基于训练数据中出现的所有词条构建。

通过查看每隔200个特征,我们可以大致了解词汇表的样子。可以看到它看起来相当杂乱,包含带数字的单词以及拼写错误的单词。通过检查get_feature_names_out()的长度,可以看到我们正在处理超过53,000个特征。

接下来,我们使用transform方法将X_train中的文档转换为文档-词条矩阵,从而得到X_train的词袋表示。这个表示存储在一个SciPy稀疏矩阵中,其中每一行对应一个文档,每一列对应训练词汇表中的一个单词。矩阵中的条目是每个单词在每个文档中出现的次数。由于词汇表中的单词数量远大于单条评论中可能出现的单词数量,该矩阵的大多数条目为零。

现在,让我们使用这个特征矩阵X_train_vectorized来训练我们的模型。我们将使用逻辑回归,因为它对高维稀疏数据效果很好。

from sklearn.linear_model import LogisticRegression
model = LogisticRegression()
model.fit(X_train_vectorized, y_train)

接下来,我们将使用X_test进行预测并计算AUC分数。我们使用已拟合训练数据的向量器来转换X_test。注意,X_test中任何未在X_train中出现过的单词将被忽略。

查看我们的AUC分数,我们得到了约0.927的分数。让我们看看模型中的系数,对它们进行排序并查看10个最小和10个最大的系数。我们可以看到模型已将诸如worstworthlessjunk等词与负面评论联系起来,而将excellentlovesamazing等词与正面评论联系起来。


方法二:TF-IDF 向量化 ⚖️

上一节我们介绍了基础的词袋模型,本节中我们来看看一种不同的方法,它允许我们重新缩放特征,称为TF-IDF。

TF-IDF(词频-逆文档频率)允许我们根据词语对文档的重要性来加权。高权重给予那些在特定文档中经常出现但在整个语料库中不常出现的词语。低TF-IDF的特征要么在所有文档中普遍使用,要么很少使用且仅出现在长文档中。高TF-IDF的特征则在特定文档中频繁使用,但在所有文档中很少使用。

与使用CountVectorizer类似,我们将实例化TfidfVectorizer并将其拟合到训练数据上。由于TfidfVectorizer经历了相同的初始文档分词过程,我们可以预期它返回相同数量的特征。

然而,让我们看看一些减少特征数量的技巧,这可能有助于提高模型性能或减少过拟合。CountVectorizerTfidfVectorizer都接受一个参数min_df,它允许我们指定一个词条需要出现在至少多少个文档中才能成为词汇表的一部分。这有助于我们移除一些可能只出现在少数文档中且不太可能成为有用预测因子的单词。

例如,这里我们将传入min_df=5,这将从词汇表中移除任何出现在少于5个文档中的单词。查看长度,我们可以看到特征数量减少了超过35,000个,降至不到18,000个特征。

接下来,当我们转换训练数据、拟合模型、对转换后的测试数据进行预测并计算AUC分数时,我们可以看到我们再次得到了约0.927的AUC分数。虽然AUC分数没有提高,但我们能够使用更少的特征获得相同的分数。

让我们看看哪些特征具有最小和最大的TF-IDF值。具有最小TF-IDF的特征列表要么普遍出现在所有评论中,要么仅出现在非常长的评论中。具有最大TF-IDF的特征列表包含那些在评论中频繁出现但并未普遍出现在所有评论中的单词。查看新模型中最小和最大的系数,我们再次可以看到模型将哪些单词与负面和正面评论联系起来。


提升:引入N-gram特征 🔄

我们之前词袋方法的一个问题是忽略了单词顺序。例如,“not an issue, phone is working”和“an issue, phone is not working”被视为相同。我们当前的模型将这两条评论都视为负面评论。

添加上下文的一种方法是添加称为N-gram的单词序列特征。例如,二元语法(bigrams)统计相邻的单词对,可以为我们提供诸如“is working”与“not working”的特征。三元语法(trigrams)提供相邻单词的三元组,可以为我们提供诸如“not an issue”的特征。

为了创建这些N-gram特征,我们将向参数ngram_range传入一个元组,其中的值对应于序列的最小长度和最大长度。例如,如果我传入元组(1, 2)CountVectorizer将使用单个单词以及二元语法来创建特征。

让我们看看通过向模型添加二元语法能实现什么样的AUC分数。请记住,虽然N-gram在捕捉含义方面可能很强大,但更长的序列可能导致特征数量爆炸式增长。仅通过添加二元语法,我们的特征数量已增加到近200,000个。

在对这些新特征训练逻辑回归模型后,看起来通过添加二元语法,我们能够将AUC分数提高到0.967。如果我们看看模型将哪些特征与负面评论联系起来,可以看到我们现在有诸如“no good”和“not happy”这样的二元语法。而对于正面评论,我们有“not bad”和“no problems”。如果我们再次尝试预测“not an issue, phone is working”和“an issue, phone is not working”,我们可以看到我们的最新模型现在能正确地将它们分别识别为正面和负面评论。


总结与扩展 📝

本教程中介绍的向量化器非常灵活,也支持诸如移除停用词或词形还原等任务,请务必查阅文档以获取更多信息。

本节课中我们一起学习了情感分析的基本流程:从数据清洗、使用CountVectorizer构建词袋模型,到应用TfidfVectorizer进行特征加权,最后通过引入ngram_range参数来捕获上下文信息以提升模型性能。这些工具为处理文本数据提供了强大的基础。

17:文本语义相似度

概述

在本节课中,我们将要学习文本语义相似度的概念、应用以及如何利用现有资源和工具进行计算。语义相似度是自然语言处理中的核心任务之一,它帮助我们理解词语和文本片段在含义上的接近程度。

什么是语义相似度?

语义相似度用于衡量两个词语或文本片段在含义上的接近程度。例如,判断“鹿”和“麋鹿”的相似度是否高于“鹿”和“长颈鹿”。

语义相似度的应用

语义相似度在自然语言处理中有着广泛的应用。

以下是其主要应用场景:

  • 语义概念分组:将含义相似的词语归类到同一语义概念下。
  • 自然语言理解任务的基础:作为更复杂任务的构建模块。
  • 文本复述:将句子重写为含义相同但表述不同的句子。
  • 文本蕴含:判断一个句子是否可以从另一段文本中推导出其含义。

语义相似度资源:WordNet

WordNet 是一个通过语义关系将词语连接起来的语义词典。它是英语中发展最完善的资源,目前也支持多种其他语言。

WordNet 包含丰富的语言学信息。

以下是其主要信息类型:

  • 词性:标注词语是名词、形容词还是动词。
  • 词义:同一个词语的不同含义。
  • 同义词:含义相同的其他词语。
  • 上下位关系:例如,“鹿”是一种“哺乳动物”。
  • 整体部分关系:描述整体与部分的关系。
  • 派生相关形式:词语的派生形式。

WordNet 是机器可读且免费可用的,因此被广泛用于自然语言处理和文本挖掘任务。

基于WordNet的语义相似度计算

WordNet 以层次结构(树状)组织信息。每个词性(如名词、动词)都有一个虚拟根节点,所有词语都组织在这个层次结构中。

例如,“鹿”、“麋鹿”、“长颈鹿”、“马”等词语在这个层次结构中以特定方式分组。“麋鹿”是“鹿”的一种,因此它们是父子关系。“鹿”和“长颈鹿”都是“反刍动物”,因此它们是兄弟节点。“马”与它们的关系较远,虽然同属“有蹄类动物”,但不在同一直接分支下。

路径相似度

一种利用此层次结构定义语义相似度的方法是路径相似度。其核心思想是计算两个概念在层次树中的最短路径距离,相似度与此距离成反比。

公式相似度 = 1 / (距离 + 1)

示例计算

  • “鹿”与“麋鹿”距离为1,相似度为 1/(1+1) = 0.5
  • “鹿”与“长颈鹿”距离为2,相似度为 1/(2+1) ≈ 0.33
  • “鹿”与“马”距离为6,相似度为 1/(6+1) ≈ 0.14

最低公共包含与Lin相似度

另一种方法是找到两个概念的最低公共包含,即层次树中同时是两者祖先且位置最低的节点。

  • “鹿”与“麋鹿”的LCS是“鹿”。
  • “鹿”与“长颈鹿”的LCS是“反刍动物”。
  • “鹿”与“马”的LCS是“有蹄类动物”。

Lin相似度 基于LCS所包含的信息量来计算相似度。

公式sim_Lin(u, v) = 2 * log P(lcs(u, v)) / (log P(u) + log P(v))
其中,P(x) 表示概念 x 在大规模语料库中出现的概率(信息内容)。

在Python中使用NLTK计算语义相似度

Python的NLTK库提供了便捷的方法来使用WordNet及其相似度度量。

首先,需要导入必要的模块并获取词语的正确词义。

import nltk
from nltk.corpus import wordnet, wordnet_ic

# 获取词语的词义(这里取名词的第一个词义)
deer = wordnet.synset('deer.n.01')
elk = wordnet.synset('elk.n.01')
horse = wordnet.synset('horse.n.01')

然后,可以计算路径相似度。

# 计算路径相似度
print(deer.path_similarity(elk))   # 输出例如 0.5
print(deer.path_similarity(horse)) # 输出例如 0.14285714285714285

要计算Lin相似度,需要基于语料库(如布朗语料库)的信息内容。

# 加载信息内容数据
brown_ic = wordnet_ic.ic('ic-brown.dat')

# 计算Lin相似度
print(deer.lin_similarity(elk, brown_ic))   # 输出例如 0.77
print(deer.lin_similarity(horse, brown_ic)) # 输出例如 0.86

注意,Lin相似度的结果可能与路径相似度不同,因为它不仅考虑结构距离,还融入了词语在实际语料中的分布信息。

基于分布相似度与共现的方法

“观其伴,知其义”是分布相似度的核心思想。经常出现在相似上下文中的词语,更可能在语义上相关。

例如,在“在咖啡馆见面”、“在披萨店偶遇”、“在咖啡店附近碰头”这些句子中,“咖啡馆”、“披萨店”、“咖啡店”、“餐厅”这些词因为频繁出现在“见面”、“在…”、“附近”等相似上下文(如前接冠词、后接地点介词、窗口内有“见面”类动词)中,所以被认为是语义相关的。

上下文可以定义为目标词前后特定窗口内的词语、词性标签,甚至是整个句子或段落。

点间互信息

为了衡量词语与上下文共现的强度,并避免像“the”这样的高频词主导结果,我们常使用点间互信息

公式PMI(x, y) = log( P(x, y) / (P(x) * P(y)) )
其中,P(x, y) 是x和y共同出现的概率,P(x)P(y) 是它们各自独立出现的概率。

在NLTK中,可以方便地计算PMI来寻找强共现词对。

from nltk.collocations import BigramAssocMeasures, BigramCollocationFinder
from nltk.tokenize import word_tokenize

# 假设 text 是您的文本语料(字符串)
tokens = word_tokenize(text.lower()) # 分词并转为小写

# 创建二元组查找器
finder = BigramCollocationFinder.from_words(tokens)

# 应用频率过滤,例如只保留出现10次以上的二元组
finder.apply_freq_filter(10)

# 使用PMI度量获取前10个最相关的二元组
pmi_measures = BigramAssocMeasures()
top_pmi_pairs = finder.nbest(pmi_measures.pmi, 10)
print(top_pmi_pairs)

总结

本节课我们一起学习了文本语义相似度。我们了解到,衡量词语和文本间的相似度并非易事,但存在像WordNet这样的丰富资源可供利用。WordNet和NLTK库提供了多种可用的相似度计算函数(如路径相似度、Lin相似度),是进行词语乃至文本间相似度计算的实用工具。这些相似度度量是许多自然语言理解任务(如复述、蕴含、主题建模)的重要基础。我们还介绍了基于分布假设和点间互信息的共现分析方法,这是从大规模文本数据中自动发现语义关联的另一种强大手段。

18:主题建模技术 🧠

在本节课中,我们将要学习主题建模技术。这是一种用于从大量文本集合中发现隐藏主题的文本挖掘方法。我们将了解什么是主题、文档如何由多个主题混合而成,以及主题建模的基本原理。

概述

主题建模是一种对文档集合进行宏观层面分析的技术。当我们拥有一个大型语料库并希望理解其内容时,可以使用主题建模来发现其中隐含的主题结构。这有助于我们组织和理解海量文本数据。

什么是主题建模?

让我们以一篇来自《科学》杂志的文章为例,文章标题为“探寻生命的基本遗传需求”。浏览文章时,你会注意到一些单词被高亮显示。

  • 黄色高亮的单词,如“基因”和“基因组”。
  • 蓝色高亮的单词,如“计算机”、“预测”和“计算分析”。
  • 粉色高亮的单词,如“生物体”、“生存”或“生命”。

这演示了任何一篇文章都可能由不同的主题或子单元交织而成,它们无缝地编织在一起构成完整的文章。这是文本挖掘领域一项重要研究——主题建模的基础。

具体来说,这个例子基于潜在狄利克雷分配模型。其中存在三个主题:黄色代表的遗传学、蓝色代表的计算科学,以及粉色代表的生命科学相关主题。这表明文档通常是多个主题的混合体。

  • 每个主题本质上是一组更可能出现在该主题下的单词。
  • 当你谈论“基因”和“DNA”时,你很可能处于遗传学领域。
  • 当你谈论“计算机”、“数字”和“数据”时,你很可能处于计算科学领域。

当一篇新文档(例如这篇关于生命基本遗传需求的文章)出现时,它带有自己的主题分布。对于这篇文章,假设世界上只有四个主题(遗传学、计算科学、生命科学、解剖学),那么这篇文章就是由这四个主题以某种单词组合方式生成的。其中,解剖学主题缺席,计算科学主题出现的概率最高,遗传学主题也有同等体现,生命科学主题则占一小部分。

主题与文档的关系

上一节我们介绍了主题建模的基本概念,本节中我们来看看主题和文档的具体定义。

主题 是话语的主题或主旨。主题由词分布表示,这意味着每个单词在该主题下出现的概率不同。例如,在“体育”主题下,单词“篮球”、“球员”、“场地”或“得分”出现的可能性更高。虽然“团队”也可能出现在社会科学研究中,但在体育主题下出现的概率更高。因此,一个主题就是所有单词上的一个概率分布。

文档 被假定为多个主题的混合体。例如,一个文档可能包含:

  • 关于遗传学主题的单词,如“人类”、“基因组”、“DNA”。
  • 关于进化主题的单词,如“进化”、“物种”、“生物体”。
  • 关于疾病主题的单词,如“疾病”、“宿主”、“细菌”。
  • 关于计算机模型主题的单词,如“计算机”、“模型”、“信息”、“数据”。

这些主题就是词分布,例如,一个主题对应一列单词,这些单词可能按概率从高到低排序。在第四个主题中,“计算机”或“模型”是最可能出现的单词。

主题建模的任务

当我们进行主题建模时,已知的输入是一个文本集合或语料库,并且我们通常需要指定主题的数量(例如,我们感兴趣的是20个主题)。目标是从这个大型集合中分组单词并找出这20个主题。

以下是未知的部分:

  • 实际的主题内容:我们并不知道具体会是哪20个主题,我们需要找到的是具有较高连贯性的20个主题。
  • 每个文档的主题分布:我们不知道某个特定文档是完全关于体育的,还是50%关于体育、50%关于遗传学。

本质上,主题建模是一个文本聚类问题。然而,在这个特定场景中,文档和单词是同时被聚类的。我们需要弄清楚哪些单词会聚集在一起(即哪些单词在语义上相似或相关),以及哪些文档会聚集在一起(即哪些文档主要关于相同主题),并基于这些文档推导出单词的分布。

常见的主题建模方法

不同的主题建模方法层出不穷,计算机科学文献中会定期提出新的模型。

以下是两种最常见且开创该领域的方法:

  1. 概率潜在语义分析:于1999年首次提出。
  2. 潜在狄利克雷分配:于2003年提出。

LDA 是目前最流行的主题模型之一,我们将在下一个视频中更详细地讨论它。

总结

本节课中我们一起学习了主题建模技术。我们了解到文档是多个主题的混合体,而每个主题则是一个单词的概率分布。主题建模的目标是从给定的文本语料库中,自动发现隐藏的主题结构以及每个文档的主题构成,这是一个文档与单词同时聚类的过程。LDA 是其中最具代表性的方法之一。

19:生成模型与LDA算法

在本节课中,我们将详细探讨生成模型以及LDA(潜在狄利克雷分布)算法。我们将从生成模型的基本概念入手,逐步理解LDA的工作原理及其在文本分析中的应用。

概述

生成模型是一种用于描述数据生成过程的统计模型。在文本分析中,生成模型假设文档是由一个或多个“主题”生成的,每个主题又对应一个特定的词语分布。LDA是一种广泛使用的生成模型,它能从文档集合中自动发现潜在的主题结构。

生成模型详解

上一节我们概述了生成模型,本节中我们来看看其具体的工作机制。

文本的生成模型基本上从这个神奇的箱子开始。假设你有一个箱子,词语会神奇地从里面出来。你从这个箱子里挑选词语来创建你的文档。当你开始取出词语时,你开始看到像“Harry”、“Potter”、“is”这样的词,然后你还有其他词如“movie”、“and”、“the”等等。仅仅通过看前两个词,你就知道这个箱子给出的词与哈利·波特有关。这是一个倾向于给出哈利·波特相关词语的词语分布。然后,你可以使用这些出来的词语来创建这个文档,生成的文档可能是这样的:“The movie Harry Potter is based on books from JK Rowling。”

在生成过程中,你有一个输出词语的模型,然后你使用来自该模型的词语来生成文档。

但是,你也可以反过来操作。你可以从文档开始,查看单词“the”出现了多少次,“Harry”出现了多少次,“Potter”出现了多少次。然后创建一个词语的分布。也就是说,你创建一个概率分布,描述在这个文档中看到单词“Harry”或单词“movie”的可能性有多大。你注意到,当你推断这个模型时,单词“the”是最频繁的。它的概率是0.1,这意味着每十个词中就有一个是“the”。然后你有“is”,接着是“Harry”和“Potter”等等。

请注意,因为文档是关于哈利·波特的,所以模型倾向于使用“Harry”和“Potter”这些词。在任何其他主题模型或任何其他文档语料库中,你不太可能看到“Harry”和“Potter”如此频繁地出现。

这里你有一个非常简单的生成过程:你有一个主题模型,你从那个主题模型中取出词语来创建你的文档。这是一个生成故事。

混合主题模型

然而,在大多数情况下,生成故事可能非常复杂。假设你不是有一个,而是有四个主题模型,四个箱子。你有一顶神奇的帽子,它随机地从这些箱子中取出词语,或者它有自己的策略来选择其中一个箱子而不是另一个,然后你得到这些出来的词语,你仍然用它们创建这个文档。

现在你的模型更复杂了,因为你需要学习四个模型。生成过程几乎就像你决定词语来自哪个箱子,一旦你做出了选择,那么你就有来自那个箱子的词语的不同分布。你仍然创建相同的文档。但是,当你使用这些文档来推断你的模型时,你需要推断四个模型,并且需要以某种方式推断来自这四个箱子(即这四个主题)的词语组合。因此,你不仅必须以某种方式弄清楚各个主题模型(即各个词语分布)是什么,还要弄清楚这个混合模型,即你如何使用这四个主题模型并将它们组合起来创建一个文档。

这通常被称为混合模型。我们在上一张幻灯片中看到的第一个模型是Unigram模型,你有一个主题分布并从那里获取词语,而这里是一个主题的混合,所以同一个文档由四个不同的主题生成,其中一些主题以较高的比例表示,而另一些则不是。这应该让你想起我们开始讨论主题模型时的例子,那是关于科学文章中的基本需求,你看到了一个关于计算的主题模型,另一个关于遗传学的主题模型,第三个关于解剖学的主题模型在文档中表现得不太好,等等。所以这里的模型是类似的。

LDA生成模型

LDA是另一个这样的生成模型。文档D的生成模型是:你选择文档的长度,你首先决定要生成的文档的长度是多少。然后你为该文档选择一个主题混合。然后你使用该主题的多项分布(即词语分布)来输出词语以填充该配额,即该主题的配额。假设你决定对于某个特定文档,40%的词语来自主题A,那么你使用主题A的多项分布来输出那40%的词语。

这是对LDA的一个非常简化的解释。你们中的一些人可能见过更复杂的数学符号,称为板符号,用来定义主题模型,这我们留待以后学习,我可以在阅读列表中指出一些相关内容。但现在,理解LDA也是一个生成模型,它基于文档长度、文档中主题的混合以及各个主题的多项分布这些概念来创建这些文档,这就足够了。

实践中的考虑

在实践中,当你创建像LDA这样的模型时,你需要问的问题是:你想要多少个主题?没有一个好的答案,找到甚至猜测这个数字实际上非常困难。所以你必须以某种方式说,好吧,我相信可能有五个主题,或者我宁愿学习五个不同的主题,而不是25个彼此非常相似的主题。

因此,你可能只是基于对这些主题可能有多独特的猜测来做出选择。但如果你在一个对这些主题有点了解的领域,例如,你拥有所有医学文档,并且你知道这些医学文档来自放射学、病理学、神经学等这些分支,那么你可能会说,好吧,我对医学的这七个分支感兴趣,这些就是我的主题,这样你对应该有多少个主题就有了一些概念。

另一个大问题是解释主题。你会得到主题,但主题只是词语分布。它们只是告诉你哪些词语在特定主题中更频繁、更可能出现,哪些不那么可能出现。但理解这一点或为主题生成一个连贯的标签是一个主观的决定。有一些研究致力于为这些主题生成名称,但很可能,每当你在主题模型中看到一个名称时,它都是手动产生的,人们只是看着这些词,比如“genetics”、“genes”等等,然后说这是遗传学主题,或者说“computation”、“model”、“data”、“information”,然后说与计算或计算机科学或信息学有关的东西。所以这些名称是相当主观的。但你从LDA中学到的实际主题基本上是一个优化函数的解,因此在这个意义上,它更偏向于确定性。

主题建模的应用与步骤

总结来说,主题建模是探索性文本分析的一个强大工具,它帮助你回答这些文档是关于什么的、这个语料库是关于什么的问题。你可以把它看作是一堆推文、评论或新闻文章的语料库,所以你可能会得到一大堆推文,然后说人们在推文中谈论什么?推文中出现了哪些不同的主题?

有许多工具可以在Python中相当轻松地完成这项工作。让我们举一个如何做的例子。有许多可用的包,其中一些是gensim、LDA等等。我们将在接下来的几张幻灯片中讨论gensim。

但在使用任何这些包之前,你需要预处理文本,我鼓励你回想一下我们在本课程早期,即在第一个模块中关于预处理文本的讨论:你需要对文本进行分词和规范化,这意味着将它们转换为小写(决定是否应该转换为小写),移除停用词。停用词是在特定领域中频繁出现且没有意义的常见词语,例如,在通用英语中,“the”、“is”等可能是你想要移除的词。而如果你在医学文档领域,比如说临床笔记,你总是看到“patient”和“doctor”等词,它们可能不像其他词如“medication”和“disease”那么重要,那么你可能想说“patient”和“doctor”是该上下文中的停用词。

另一个预处理步骤是词干提取,这意味着你需要移除派生相关形式,以某种方式将派生相关形式规范化为同一个词,例如“meet”、“meeting”、“met”都应该称为“meet”。一旦你完成了预处理步骤,你将这个分词后的文档转换为文档-词项矩阵,即从“哪个文档有哪些词”到“哪些词出现在哪些文档中”,得到那个文档-词项矩阵将是重要的第一步,用于LDA的工作。

然后,一旦你完成了这些,一旦你构建了这个文档-词项矩阵,你就在其上构建LDA模型。

使用Gensim实现LDA

一旦你在词项和文档之间建立了映射,那么假设你在这个变量doc_set中有一组预处理过的文本文档。

以下是使用Gensim学习LDA的方法:

import gensim
from gensim import corpora, models

# 创建词典(词语到ID的映射)
dictionary = corpora.Dictionary(doc_set)

# 创建语料库(文档-词项矩阵的表示)
corpus = [dictionary.doc2bow(doc) for doc in doc_set]

# 构建LDA模型
lda_model = models.LdaModel(corpus=corpus,
                            id2word=dictionary,
                            num_topics=4,
                            passes=10)

首先,你导入Gensim,特别是导入corporamodels。首先你创建一个词典,词典是ID和词语之间的映射。然后你创建语料库,通过遍历doc_set中的所有文档并创建一个文档的词袋模型来创建语料库,这是创建文档-词项矩阵的步骤。

一旦你有了这些,然后你将其输入到LDA模型调用中,这样你使用Gensim的models.LdaModel,在这里你还需要指定你想要学习的主题数量,在这个例子中我们设主题数为4,你还指定这个ID到词语的映射,即前两步学到的词典。

一旦你学习了模型,就这样了,你可以指定它应该进行多少次遍历,还有其他参数我鼓励你去阅读。但一旦你定义了这个LDA模型,你就可以使用LDA模型来打印主题。在这个特定情况下,我们学习了四个主题,你可以说给我这四个主题的前五个词,然后它会为你打印出来。

LDA模型也可以用于查找文档的主题分布。所以当你有一个新文档并对它应用LDA模型时,即你推断它,你可以说对于这个新文档,在这四个主题上的主题分布是什么?

总结与核心概念

本节课中我们一起学习了主题建模的核心概念和应用。

这里的核心概念是:主题建模是文本挖掘中常用的探索性工具。LDA是一种生成模型,广泛用于对大型文本语料库进行建模。还有其他可用的主题模型,PLSA是另一个。

除了作为探索性工具,LDA还可以用作文本分类或任务的特征选择技术。例如,如果你想移除语料库中非常常见的词语带来的所有特征,或者你想将特征仅聚焦于来自特定主题的那些特征,那么你会想先训练一个LDA模型,然后基于主题,仅从感兴趣的主题中产生的词语,你实际上可以那样生成特征。

总的来说,LDA是一个非常强大的工具和文本聚类工具,通常作为理解语料库内容的第一步。

希望你已经学会了如何使用主题建模,这为你提供了对主题模型的简要介绍,其中有很多内容你可以更深入地研究,但对于本课程,我们就讲到这里。

20:信息抽取技术

在本节课中,我们将深入学习如何将本课程所学的知识整合起来,应用于一项非常重要的自然语言处理任务——信息抽取。这项任务对于理解自由文本至关重要。

概述:从非结构化文本中提取信息

正如我们多次所见,信息以非常有趣的方式隐藏在自由文本中。传统的交易信息通常是结构化的,而如今大量信息以非结构化的自由文本形式存在。正如课程开始时介绍的,大约80%的数据(如博客、网站内容等)都是非结构化的,并且随着推特等社交媒体的发展,这类数据还在不断增长。

因此,核心问题就变成了:如何将非结构化文本转换为结构化形式?我们并非必须转换所有文本,更重要的是如何从中提取相关信息。为了使信息可搜索或后续可用,通常需要将其放入传统的结构化形式中。这就是信息抽取发挥作用的地方。

信息抽取的目标是从自由文本中识别并提取感兴趣的字段。

信息抽取的核心概念

上一节我们介绍了信息抽取的基本目标,本节中我们来看看其核心组成部分。

命名实体识别

命名实体识别依赖于所谓的“命名实体”。命名实体是具有特定类型、指代特定个体、地点、组织等的名词短语。命名实体识别任务是一系列技术和方法,旨在帮助识别文本中所有预定义命名实体的提及。

例如,识别一个提及时,需要知道该提及从何处开始、在何处结束。这是命名实体识别中的边界检测子任务。确定边界后,下一个任务是将其分类标记为你感兴趣的命名实体类型之一。

以下是一个在医学文档上下文中的命名实体任务示例:

患者为63岁女性,因双手偶发性麻木就诊于神经科。检查显示C5-6椎间盘突出。

以下是可能感兴趣的命名实体:

  • 疾病/症状:双手麻木、偶发性无力、C5-6椎间盘突出。
  • 年龄:63岁。
  • 性别:女性。
  • 医学专科:神经科。
  • 医疗程序:检查、MRI。
  • 身体部位:手、脚。

需要注意的是,这些命名实体可能相互嵌套。例如,“双手麻木”本身是一个有效的命名实体,而“手”也是一个值得关注的实体。因此,在定义命名实体任务时,需要决定标注的粒度。例如,可以决定只关注诊断、年龄、性别、执行过的程序和专科,而不单独标注身体部位。

关系抽取

关系抽取任务是识别命名实体之间的关系。

以我们之前看到的句子为例:“Herbites helps treat advanced lung cancer.” 这个句子包含两个命名实体之间的关系:“Herbites”(一种治疗方式,标记为黄色)和“lung cancer”(一种疾病,标记为绿色)。它们之间通过一个治疗关系相连,表明“Herbites是肺癌的一种治疗方法”。这是一个非常简单的二元关系。

共指消解

共指消解任务是消除文本中提及的歧义,并将指向同一实体的提及分组。

例如:“Anita在市场上遇到了Joseph。他送了她一朵玫瑰,让她很惊喜。” 这里有两个命名实体:Anita和Joseph。第二句使用代词“他”指代Joseph,“她”指代Anita。共指消解就是推断出“Joseph送了Anita一朵玫瑰”。

信息抽取的应用

以上这些任务共同构成了更高级应用的基础,例如问答系统。要正确回答问题,通常需要结合命名实体识别、关系抽取和共指消解。

例如:

  • 问题:“Herbites治疗什么?”
    • 首先需要识别“Herbites”是一种治疗方式(命名实体识别)。
    • 然后找到“治疗”关系(关系抽取)。
    • 最后填充关系中的空位,得出答案:“肺癌”。
  • 问题:“谁送了Anita玫瑰?”
    • 需要进行共指消解,识别出“他”指代Joseph。
    • 结合关系理解,得出答案:“Joseph”。

实现方法

现在我们已经了解了任务本身,接下来看看人们通常使用哪些方法来识别命名实体。这很大程度上取决于我们处理的实体类型。

以下是主要方法:

  • 规则方法(如正则表达式):适用于格式规范的字段,如日期和电话号码。我们在第一周课程中学习的日期正则表达式就是一个非常有效的命名实体识别任务。
  • 机器学习方法:对于其他字段,使用机器学习方法非常普遍。即使是日期和电话号码,也可以使用机器学习方法,将正则表达式作为特征之一,并结合其他上下文特征(例如,区分“电话:”和“传真:”前的数字)来训练模型。

标准的自然语言处理NER任务通常是一个四分类模型,包括人物(PERSON)、组织(ORGANIZATION)、地点(LOCATION)其他(OUTSIDE)

例如,对于句子“John met Brenda.”:

  • John → PERSON
  • met → OUTSIDE
  • Brenda → PERSON

命名实体识别系统使用我们在课程中讨论过的监督式机器学习方法和文本挖掘方法。例如:

  • 如果要识别的实体是日期,通常使用第一周讨论的正则表达式
  • 如果要提取人名或组织名,不仅会使用机器学习模型来识别实体及其标签,还会使用第二周讨论的特征,例如:单词是否大写、词性(是名词还是动词)、在句子上下文中的语义角色等。

NLTK内置了一个NER模型,该模型在新闻数据集上针对标准的人物、组织、地点任务进行了训练。但正如本视频所示,命名实体识别问题远不止于新闻领域。当扩展到金融,尤其是医学领域时,它完全是一个开放性问题,需要综合运用本课程所讨论的所有主题。

总结

本节课中我们一起学习了信息抽取技术。我们了解到,信息抽取是自然语言理解和处理文本数据的一项重要任务,是将非结构化文本转换为更结构化形式的第一步。命名实体识别是解决这些任务及高级NLP任务的关键基石。通过结合规则方法、机器学习模型以及词性、语义等特征,我们可以从文本中有效地提取出有价值的结构化信息。

21:网络定义与研究意义

在本节课中,我们将学习网络的基本概念,并探讨研究网络的意义。我们将从网络的定义开始,然后了解网络在现实世界中的各种表现形式,最后讨论通过分析网络结构可以解决哪些实际问题。

什么是网络?🤔

网络是一个由对象及其相互关系构成的集合。我们将这些对象称为节点,将对象之间的关系称为。一个网络可以形式化地表示为:

G = (V, E)

其中,V 代表节点集合,E 代表边集合。

为什么研究网络?🌐

研究网络的首要原因是网络无处不在。接下来,我们将展示网络在不同领域中的具体表现。

以下是网络在不同场景下的几个例子:

  • 社交网络:在社交网络中,节点是人,边代表人与人之间的关系。例如,一个由34名空手道俱乐部成员组成的网络,边代表他们之间的友谊。在这个例子中,1号节点是俱乐部教练,其他节点是学员。
  • 电子邮件通信网络:网络也可以基于线上关系构建。例如,一个由436名惠普员工组成的网络,边代表他们之间的电子邮件通信。
  • 交通与移动网络:这类网络描述了实体在空间中的移动。例如,一个显示全球不同机场间直达航班的网络。另一个例子是基于美元钞票追踪数据构建的人类移动网络,通过钞票的流转路径形成网络。此外,还有安阿伯市的公交网络,边代表从一个公交站到下一站的直达路线。
  • 信息网络:节点是信息单元,边是它们之间的链接。例如,一个政治博客网络,节点是博客,边代表通过URL建立的博客间链接。该网络显示,左翼博客主要链接其他左翼博客,右翼博客亦然,这种现象称为聚类。另一个例子是关于气候变化的维基百科文章网络,边代表文章间的直接链接,同样可以看到按子主题形成的聚类。
  • 生物网络:网络在生物学中也很常见。例如,一个蛋白质相互作用网络,节点是蛋白质,边表示它们之间是否相互作用。还有一个代表食物链的网络,描述动物之间的捕食关系。

网络还出现在金融、贸易、引文等众多领域。这充分说明网络存在于各种不同的情境中。本课程将主要关注社交网络,但网络分析的方法在许多其他领域同样适用。

网络分析能做什么?🔍

网络无处不在,但这足以成为我们研究它的理由吗?当我们把复杂现象表示为网络时,我们能做些什么?通过研究网络结构,我们能否更好地理解这些现象?

答案是肯定的。通过分析网络结构,我们可以解答许多问题。

以下是几个具体例子:

  • 分析电子邮件网络:在一个由436名员工组成的电子邮件通信网络中,我们可以研究:如果谣言从网络的某个部分开始,它是否可能传播到整个网络?谁是组织中最有影响力的人?与从网络边缘节点开始的谣言相比,从网络中心区域(如某个核心人物)开始的谣言是更容易还是更难传播?
  • 分析空手道俱乐部网络:观察空手道俱乐部成员间的友谊网络,我们可以预测:这个俱乐部是否可能分裂成两个不同的俱乐部?事实上,这个故事真实发生了。如果你试图猜测分裂后每个成员会加入哪个俱乐部,你可以观察网络结构做出有根据的推测。从图中可以看出,俱乐部的分裂大致发生在这里,这条线左侧的节点加入一个俱乐部,右侧的节点加入另一个俱乐部。
  • 分析全球航班网络:观察全球直达航班构成的交通网络,我们可以回答:如果世界上爆发流行病或病毒,是否有某些机场需要我们给予比其他机场更多的关注?或者,如果世界上某些地区通过航空运输难以到达,我们可以建立哪些关键连接来改善可达性?

这些都是通过研究网络结构可以探讨的问题,其中许多问题我们将在课程后续部分再次提出,并为你提供理解这些问题所需的工具。

总结 📝

本节课中,我们一起学习了网络的基本概念及其研究意义。

许多复杂结构都可以通过网络来建模,我们看到了来自社交网络、生物网络、交通网络等领域的例子。

通过研究这些网络的结构,我们可以开始解答涉及复杂关系的、相当复杂的问题。网络通过将复杂现象表示为节点和边的集合,并借助特定的分析工具,使我们能够简化并理解这些现象。

在本课程中,我们将介绍一些研究社交网络的基本技术,并解答关于它们的一些非常有趣的问题。

22:网络定义与术语体系

在本节课中,我们将学习网络科学的基础定义和术语体系。我们将了解不同类型的网络,并学习如何使用Python的NetworkX库来构建和表示这些网络。

网络基础定义

上一节我们讨论了研究社交网络的原因及其在各种场景中的应用。本节中,我们将更详细地了解课程中将使用的不同网络定义和词汇,并开始学习如何使用Python中的NetworkX来构建我们将要研究的几种网络类型。

首先,网络或图(我们也称之为图)是不同集合之间连接关系的表示。

以下是一个示例,其中包含一组我们称为节点的东西。这些是带有标签A到G的圆圈,我们称这些东西为节点或顶点。

然后,它们之间存在可以代表各种不同事物的连接。我们称这些连接为边,有时我们也称它们为链接或纽带。

我们将使用Python中的NetworkX来处理我们将要研究的一些网络。因此,你需要知道的第一件事是如何在NetworkX中创建一个网络。

使用NetworkX创建网络

以下是创建网络的基本步骤。

首先,我们需要导入NetworkX库,然后使用Graph类来表示我们在这里看到的网络。这里,我创建了一个Graph类的实例G

接下来,我可以添加边。例如,这里我添加了边AB,也就是这里的这条边。

然后,我会添加下一条边,即边BC,并可以继续添加所有其他边。请注意,我还没有添加节点本身。当你添加边时,如果边所连接的一对节点尚未存在于图中,NetworkX会自动将它们添加到图中。这非常方便。

import networkx as nx

# 创建一个无向图
G = nx.Graph()

# 添加边,节点会自动添加
G.add_edge('A', 'B')
G.add_edge('B', 'C')
# 继续添加其他边...

无向图与有向图

现在让我们回到之前见过的一个网络。这是一个人与人之间的网络,这里的边代表友谊、婚姻关系和家庭关系,涉及2200人。

观察这个网络时,你会发现这些边大多是对称关系。我的意思是,如果A是B的朋友,那么B通常也是A的朋友。因此,这个网络具有对称关系。

但情况并非总是如此。如果你看这个代表动物捕食关系的网络,这些关系是非常不对称的。例如,如果有一条从鱼指向鹰的边,表示鹰吃鱼,这与相反方向的意义截然不同。因此,在这个网络中,边的方向对于边所要表示的内容具有非常重要的意义。

这表明我们至少需要两种不同类型的网络:一些是无向的,意味着边没有方向,或者边的方向并不重要,我们称这些为无向边。在NetworkX中,我们使用Graph类来表示这些类型的网络,就像我之前展示的那样。

但是,如果我们想要一个真正有方向的网络,比如食物网的例子,那么你就需要有向边。要使用NetworkX来表示这样的网络,我们将使用DiGraph类,它代表有向图。然后我们会添加边,例如添加边B->A,然后添加边B->C。你会注意到现在顺序很重要,因为添加边B->C与添加边C->B是非常不同的,现在这些边具有方向。

# 创建一个有向图
DG = nx.DiGraph()
DG.add_edge('B', 'A')
DG.add_edge('B', 'C')

加权网络

接下来我们要看的网络类型是加权网络。这里的直观理解是,在某些网络中,一些边承载的权重与其他边不同,因此我们需要一种方法来捕捉这一点。

让我从一个这样的网络示例开始。这是一个节点代表人的网络,边代表他们一起共进午餐的次数,这些人是同事。

例如,观察边AB,A和B一起吃了6次午餐,而边CE表示节点C和E一起吃了25次午餐。这是两种非常不同的关系,因此我们需要一种方法来捕捉这些权重。

加权网络是一种为边分配特定权重的网络,通常是一个在节点间关系中具有意义的数值权重。

在NetworkX中,我们也可以使用Graph类来表示这些类型的网络,但我们要做的是在添加边时,例如边AB,我们将添加一个名为weight的属性,其值为6,这将允许我们在图上捕捉这些权重。边BC也是如此,其权重为13,依此类推。

# 创建一个加权无向图
WG = nx.Graph()
WG.add_edge('A', 'B', weight=6)
WG.add_edge('B', 'C', weight=13)

符号网络

接下来我们要看的网络类型是符号网络。有些网络不仅可以承载关于友谊的信息,还可以承载人与人之间的对立信息,这可能源于冲突或分歧。例如,网站Slashdot允许人们不仅声明谁是他们的朋友,还可以声明谁是他们的敌人。

现在让我们看看这个网络,它代表了网络中谁与谁是朋友,谁与谁是敌人。我们希望有一种方法来捕捉这一点。这被称为符号网络,现在边不是具有权重,而是具有正号或负号,代表人们是朋友还是敌人。

我们也可以在NetworkX上构建这些类型的网络,使用Graph类。现在,我们不是为边添加weight属性,而是添加一个我们称为sign的属性。因此,边AB将有一个正号,然后边BC将有一个负号。

# 创建一个符号网络
SG = nx.Graph()
SG.add_edge('A', 'B', sign='+')
SG.add_edge('B', 'C', sign='-')

边的属性

边可以有权重,可以有符号。但仔细想想,它们还可以有很多其他东西。

这是另一个例子。现在这些边被着色,颜色代表两个节点之间特定类型的关系。这里我们有代表家庭成员、朋友、同事或邻居关系的节点。

为了表示这样的东西,正如你可能想象的那样,你可以在添加边时使用另一种类型的属性。在这种情况下,我们称之为relation。当我们添加边AB时,因为它在这里是蓝色,意味着他们是朋友,那么我们将添加属性relation并赋予其值friend。B和C之间的同事关系,以及D和E之间的家庭关系也是如此。

总的来说,当我们添加这些边时,我们可以拥有任何类型的属性。我们可以为边赋予任何我们想要的属性类型。

# 创建带有关系属性的图
RG = nx.Graph()
RG.add_edge('A', 'B', relation='friend')
RG.add_edge('B', 'C', relation='coworker')
RG.add_edge('D', 'E', relation='family')

多重图

我们要看的下一种网络类型称为多重图。多重图的直观理解是,对于一对节点,没有理由不能同时拥有多种不同的关系。

让我们回到我们在这个图中看到的例子。边代表节点之间的某种关系,可能是家庭、朋友、同事或邻居关系。但没有任何东西阻止任何两个节点同时拥有两种关系。例如,这个网络总体上可能看起来更像这样,现在节点A和B之间的关系不仅是朋友,而且还是邻居;或者节点G和F之间的关系不仅是家庭成员,而且还是同事,等等。

这被称为多重图,它是一种同一对节点之间可以由多条边连接的网络,这些边也称为平行边。在NetworkX中,我们将使用一个不同的类来表示多重图,即MultiGraph类。我们要做的是,当向多重图添加一条边时,我们会添加它并赋予它一个特定的属性。然后,如果我们想添加一条具有不同属性的第二条边,我们可以再次添加它并赋予它不同的属性。实际上,你并不一定要为多重图添加属性,你可以简单地添加多条平行边,然后MultiGraph类会捕获给定节点对之间有多少条边。在这种情况下,我们正在查看不同的关系,因此我们使用了属性。

# 创建一个多重图
MG = nx.MultiGraph()
MG.add_edge('A', 'B', relation='friend')
MG.add_edge('A', 'B', relation='neighbor')  # 同一对节点间的第二条边
# 添加其余边...

总结

本节课中我们一起学习了多种不同类型的网络。

  • 无向图:我们使用Graph类来表示。
  • 有向图:我们使用DiGraph类来表示。
  • 符号网络:我们使用Graph类,但在添加边时赋予一个sign属性。
  • 多重图:我们使用MultiGraph类,它可以处理每对节点之间的多条边。
  • 加权网络:我们在添加边时,为边赋予一个weight属性。

感谢收听本讲座,我们下一讲再见。

23:节点与边属性

在本节课中,我们将学习如何在NetworkX库中为网络图中的节点和边添加属性,以及如何访问这些属性。属性可以存储额外的信息,例如边的权重、关系类型,或节点的角色。

上一节我们介绍了如何创建基本的网络图。本节中我们来看看如何为图中的元素(节点和边)添加和访问额外的信息,即属性。

添加边属性

我们可以通过在创建边时指定属性来为边添加信息。例如,可以添加表示“权重”和“关系”的属性。

以下是添加带属性边的代码示例:

import networkx as nx

# 创建一个无向图
G = nx.Graph()
# 添加边及其属性
G.add_edge('A', 'B', weight=6, relation='family')
G.add_edge('B', 'C', weight=13, relation='friend')

访问边属性

有多种方法可以访问边的属性,具体取决于你需要获取所有边的信息还是特定边的信息。

以下是访问边属性的几种方法:

  1. 获取所有边(无属性):使用 G.edges() 可以获取所有边的列表,但不包含属性。
  2. 获取所有边及其属性:使用 G.edges(data=True) 会返回一个列表,其中每个元素是一个三元组 (节点A, 节点B, 属性字典)
  3. 获取特定边的所有属性:使用 G.edges[('A', 'B')] 会返回连接节点A和B的边的属性字典。
  4. 获取特定边的特定属性:在获取属性字典后,可以通过键名访问具体属性值,例如 G.edges[('B', 'C')]['weight']

有向图中的边属性

对于有向图,边的方向至关重要。添加和访问属性的方法与无向图类似,但必须注意边的顺序。

以下是创建和访问有向图边属性的代码:

# 创建一个有向图
DG = nx.DiGraph()
DG.add_edge('A', 'B', weight=6, relation='family')
DG.add_edge('B', 'C', weight=13, relation='friend')

# 访问属性时顺序必须正确
print(DG.edges[('B', 'C')]['weight'])  # 输出:13
# print(DG.edges[('C', 'B')]['weight'])  # 这会报错,因为边不存在

多重图中的边属性

多重图允许同一对节点之间存在多条边。每条边都可以拥有独立的属性。

以下是创建和访问多重图边属性的代码:

# 创建一个无向多重图
MG = nx.MultiGraph()
MG.add_edge('A', 'B', weight=6, relation='family')
MG.add_edge('A', 'B', weight=18, relation='friend')  # 第二条A-B边
MG.add_edge('B', 'C', weight=13, relation='friend')

# 访问一对节点之间的所有边及其属性
print(MG.edges[('A', 'B')])
# 输出类似:{0: {'weight': 6, 'relation': 'family'}, 1: {'weight': 18, 'relation': 'friend'}}

# 访问特定边的特定属性(通过键索引)
print(MG.edges[('A', 'B')][0]['weight'])  # 输出:6
print(MG.edges[('A', 'B')][1]['weight'])  # 输出:18

有向多重图中的边属性

有向多重图结合了有向边和多重边的特性。需要使用 MultiDiGraph 类。

以下是创建和访问有向多重图边属性的代码:

# 创建一个有向多重图
MDG = nx.MultiDiGraph()
MDG.add_edge('A', 'B', weight=6, relation='family')
MDG.add_edge('A', 'B', weight=18, relation='friend')
MDG.add_edge('C', 'B', weight=13, relation='friend')  # 注意方向是C->B

# 访问属性
print(MDG.edges[('A', 'B')][0]['weight'])  # 输出:6
# 顺序至关重要
# print(MDG.edges[('B', 'A')][0]['weight'])  # 这会报错

添加与访问节点属性

除了边,节点也可以拥有属性。可以在添加节点时或之后为其设置属性。

以下是添加和访问节点属性的代码:

# 创建一个图并添加带属性的边
G = nx.Graph()
G.add_edge('A', 'B', weight=6, relation='family')
G.add_edge('B', 'C', weight=13, relation='friend')

# 为节点添加属性(即使节点已存在)
G.add_node('A', role='trader')
G.add_node('B', role='trader')
G.add_node('C', role='manager')

# 访问节点属性
print(G.nodes(data=True))
# 输出类似:[('A', {'role': 'trader'}), ('B', {'role': 'trader'}), ('C', {'role': 'manager'})]

# 访问特定节点的属性
print(G.nodes['C']['role'])  # 输出:manager

总结

本节课中我们一起学习了如何在NetworkX中处理节点和边的属性。我们掌握了以下核心操作:

  • 为边和节点添加自定义属性。
  • 使用 edges()nodes() 方法,配合 data 参数,来列出所有元素及其属性。
  • 通过指定节点对或单个节点来访问特定边或节点的属性。
  • 理解了在处理有向图、多重图和有向多重图时,边的方向和索引的重要性。

根据网络的大小和分析需求,你可以选择一次性获取所有属性,或是高效地查询特定元素的属性。

24:二分图结构

在本节课中,我们将学习一种特殊且非常有用的图结构——二分图。我们将了解它的定义、如何构建它,以及如何使用NetworkX库来分析它,包括检查图的二分性、获取二分划分以及计算投影图。

什么是二分图?🤔

在之前的课程中,我们学习了不同类型的图,包括无向图、有向图、多重图、带符号图和带权图等。我们尚未学习一种对特定应用非常有趣和有用的图类型,即二分图。

在给出二分图的正式定义之前,我们先通过一个例子来感受它在哪些情况下是重要的。下图展示了一个关于特定篮球队球迷的例子。

图中绿色的节点A、B、C、D、E代表喜欢篮球队1、2、3、4的球迷。边代表某个球迷是某个球队的粉丝。

这个图的一个特点是,你无法想象在球迷之间添加边是合理的,因为球迷不是其他球迷的粉丝,他们是球队的粉丝。同样,在篮球队之间添加边也不合理,因为篮球队拥有球迷,它们本身并不是其他球队的粉丝。

因此,这个图具有特定的结构:所有的边都从一组节点连接到另一组节点。在这个例子中,一组节点是球迷,另一组节点是篮球队。这正是二分图的定义。

二分图的定义 📖

更具体地说,如果一个图拥有两组节点(我们称之为L和R),并且每一条边都连接一个L组的节点和一个R组的节点,那么这个图就是一个二分图。这意味着没有边连接L组内的两个节点,也没有边连接R组内的两个节点。

用公式表示,对于一个图 G = (V, E),如果存在节点集 V 的一个划分 V = L ∪ R,且 L ∩ R = ∅,使得对于所有边 (u, v) ∈ E,都有 u ∈ Lv ∈ R,或者 u ∈ Rv ∈ L,则 G 是二分图。

在上述例子中,两组节点分别是球迷集合(作为L组)和篮球队集合(作为R组)。

在NetworkX中构建二分图 🛠️

NetworkX没有为二分图提供单独的类,但它提供了一系列算法来研究、分析并操作它们。我们需要导入 bipartite 模块来使用这些算法。

import networkx as nx
from networkx.algorithms import bipartite

要构建二分图,我们仍然使用 Graph 类。首先,我们添加节点。这里我们使用 add_nodes_from 函数,它可以一次性从列表中添加一组节点,而不是逐个添加。我们为这组节点添加一个名为 bipartite 的属性,并将其值设为 0。这实际上是告诉NetworkX,这组节点将构成我们二分图的一侧(例如左侧)。

B = nx.Graph()  # 使用B代表二分图
# 添加左侧节点(例如球迷)
left_nodes = [‘A‘, ‘B‘, ‘C‘, ‘D‘, ‘E‘]
B.add_nodes_from(left_nodes, bipartite=0)
# 添加右侧节点(例如球队)
right_nodes = [1, 2, 3, 4]
B.add_nodes_from(right_nodes, bipartite=1)

接着,我们添加边。同样,使用 add_edges_from 函数可以一次性添加一个边列表。

edges = [(‘A‘, 1), (‘B‘, 1), (‘B‘, 2), (‘C‘, 1), (‘C‘, 2), (‘D‘, 1), (‘D‘, 2), (‘E‘, 2), (‘E‘, 4), (‘H‘, 1), (‘H‘, 3), (‘J‘, 4)]
B.add_edges_from(edges)

二分图的基本操作 🔍

现在我们已经构建了二分图 B,让我们看看在NetworkX中可以对它进行哪些操作。

首先,我们可以检查一个图是否是二分图。

print(bipartite.is_bipartite(B))  # 输出:True

如果我们添加一条连接左侧节点(如A和B)的边,就会破坏二分图的规则。

B.add_edge(‘A‘, ‘B‘)
print(bipartite.is_bipartite(B))  # 输出:False

让我们移除这条边以保持图的二分性。

B.remove_edge(‘A‘, ‘B‘)

其次,我们可以检查一组节点是否是图的一个二分划分。所谓二分划分,就是指这组节点是否是满足“所有边都从一组连向另一组”条件的两组节点之一。

# 假设右侧节点集是一个二分划分
X = set([1, 2, 3, 4])
print(bipartite.is_bipartite_node_set(B, X))  # 输出:True

# 左侧节点集也是一个二分划分
Y = set([‘A‘, ‘B‘, ‘C‘, ‘D‘, ‘E‘, ‘H‘, ‘J‘])
print(bipartite.is_bipartite_node_set(B, Y))  # 输出:True

# 混合的集合则不是
Z = set([1, 2, 3, 4, ‘A‘])
print(bipartite.is_bipartite_node_set(B, Z))  # 输出:False

第三,如果我们不知道图的两组二分划分具体是什么,我们可以让NetworkX输出它们。

# 获取图的二分划分
left_set, right_set = bipartite.sets(B)
print(“左侧集合:“, left_set)
print(“右侧集合:“, right_set)

如果图不再是二分图(例如我们添加了A-B边),那么调用 sets 函数将会报错,提示图不是二分图,因此无法找到这样的两组节点。

投影图:从二分图到单模网络 🔄

让我们看一个稍大一点的二分图例子,它具有相同的含义:一侧是球迷,另一侧是篮球队。

假设你感兴趣的是在球迷之间创建一个网络,用边来表示他们在支持球队方面的某种亲和性,比如他们是否倾向于支持相同的球队。这种网络对于病毒式营销可能很重要。如果两个人倾向于支持相同的球队,他们可能也会喜欢同一种其他产品。因此,了解谁可能影响谁(就其他产品而言)会很有用,而他们支持同类球队的事实可能提供了这种线索。

要构建这样的网络,你可以通过计算二分图的投影图来实现。具体来说,L侧投影图是在二分图一侧(本例中是L侧,即球迷)节点之间构建的网络,其中如果两个节点在二分图的R侧(球队侧)至少有一个共同的邻居,那么它们之间就有一条边。

因此,在这个例子中,将会得到一个球迷之间的网络,如果两个球迷至少共同喜欢一个球队,他们就被连接起来。

类似地,可以定义R侧投影图,那将是一个篮球队之间的网络,如果两个球队至少有一个共同的球迷,它们就被连接起来。

下图展示了球迷投影图的样子:

在这个网络中,边A-H出现是因为A和H都是球队1的球迷。边J-E出现是因为他们都是球队4的球迷。

在NetworkX中,你可以使用 projected_graph 函数来获取这个投影网络。

# 定义球迷集合
fans = set([‘A‘, ‘B‘, ‘C‘, ‘D‘, ‘E‘, ‘H‘, ‘J‘])
# 计算L侧(球迷)投影图
P_fans = bipartite.projected_graph(B, fans)

如果你想要球队的投影图,只需将节点集合定义为球队集合。

# 定义球队集合
teams = set([1, 2, 3, 4])
# 计算R侧(球队)投影图
P_teams = bipartite.projected_graph(B, teams)

在这个投影网络中,节点1和2是相连的,因为它们至少有一个共同的球迷(C)。实际上,它们还有B和D作为共同球迷。而边1-3出现在投影图中,是因为球队1和3有共同的球迷H。

加权投影图:量化连接强度 ⚖️

注意到,边1-2和边1-3虽然都存在,但它们的“强度”不同:球队1和2有3个共同球迷,而球队1和3只有1个共同球迷。我们可能希望用权重来捕获这种差异。

这就是加权投影图。在加权投影图中,不仅当两个节点在另一侧有至少一个共同邻居时添加边,还会为边添加权重,权重与它们共同邻居的数量成正比。

下图展示了篮球队的加权投影图,边上的数字代表共同邻居的数量,边的粗细也与此权重成正比。

在NetworkX中,你可以使用 weighted_projected_graph 函数来获取加权投影图。

# 计算R侧(球队)加权投影图
W_teams = bipartite.weighted_projected_graph(B, teams)
# 同样可以计算球迷侧的加权投影图
W_fans = bipartite.weighted_projected_graph(B, fans)

加权投影图的边具有 weight 属性,表示共同邻居的数量。

总结 📝

本节课我们一起学习了二分图。

  • 定义:二分图包含两组节点(L和R),所有边都连接一组的一个节点和另一组的一个节点,组内没有边。
  • 构建:在NetworkX中,我们使用普通的 Graph 类来构建二分图,并通过为节点设置 bipartite 属性(如0或1)来标识它们属于哪一侧。我们利用 bipartite 模块中的算法进行分析。
  • 基本操作:我们可以检查一个图是否是二分图(is_bipartite),检查一组节点是否构成一个有效的二分划分(is_bipartite_node_set),以及获取图的两组二分划分(sets,仅在图为二分图时有效)。
  • 投影图:这是从二分图衍生出的单模网络,连接了同一侧中拥有共同邻居的节点。我们学习了如何生成普通投影图(projected_graph)和加权投影图(weighted_projected_graph),后者用权重量化了连接的强度(即共同邻居的数量)。

二分图是表示两类实体间关系的强大工具,而投影图则能帮助我们发现同类实体之间通过另一类实体产生的间接关联,这在社交网络分析、推荐系统等领域有广泛应用。

本节课到此结束,我们下节课再见。

25:NetworkX图数据加载 📊

在本节课中,我们将学习如何使用NetworkX库加载不同格式的图数据。图数据有多种存储格式,了解如何将它们正确读入NetworkX图对象是进行图分析的第一步。

概述

我们将首先创建一个示例图,并使用NetworkX的内置函数进行可视化。接着,我们会逐一探讨几种常见的图数据格式,包括邻接表、邻接矩阵和边列表,并演示如何将它们加载到NetworkX中。最后,我们将通过一个国际象棋对弈网络的实例,进行更复杂的分析和数据操作。

创建与可视化示例图

首先,我们创建一个名为G1的示例图,用于后续的格式加载演示。如果图不是特别大,我们可以使用NetworkX的内置绘图函数来快速可视化它。

以下是使用draw_networkx函数可视化图的代码示例:

import networkx as nx
import matplotlib.pyplot as plt

# 假设 G1 已经创建
nx.draw_networkx(G1)
plt.show()

现在,让我们看看这个图在不同文件格式下的表现形式,以及如何读取每种格式。

加载不同格式的图数据

图数据可以以多种格式存储。以下是几种常见格式及其加载方法。

邻接表格式

邻接表格式适用于没有节点或边属性的简单图。它由多行文本组成,每行的第一个标签是源节点,随后的标签是与该源节点相连的目标节点。

例如,文件adjlist.txt的内容可能如下所示:

0 1 2 3 5
1 2 4
2 3 5
3 4
4 5
5

第一行表示节点0与节点1、2、3、5相连。注意,后续行只添加之前未作为源节点出现过的节点。

我们可以使用NetworkX的read_adjlist函数来读取这种格式的图。通过设置nodetype=int参数,可以确保节点被读取为整数类型,而不是默认的字符串类型。

G_adjlist = nx.read_adjlist("adjlist.txt", nodetype=int)

检查图的边,可以看到它们与之前创建的G1图相匹配。

邻接矩阵格式

在邻接矩阵中,矩阵的每个元素表示一对顶点是否相邻。例如,一个NumPy数组的第一行对应节点0,如果该行在列1、2、3、5处的值为1,则表示节点0与这些节点相邻。如果是多重图,矩阵中的值可能大于1,表示节点对之间的边数。

将邻接矩阵转换为NetworkX图,只需将其传递给nx.Graph构造函数(对于无向图)或nx.DiGraph(对于有向图)。

import numpy as np
adj_matrix = np.array([[0,1,1,1,0,1],
                       [1,0,1,0,1,0],
                       [1,1,0,1,0,1],
                       [1,0,1,0,1,0],
                       [0,1,0,1,0,1],
                       [1,0,1,0,1,0]])
G_matrix = nx.Graph(adj_matrix)

检查其边,同样与之前的图一致。

边列表格式

边列表格式适用于具有简单边属性但没有节点属性的图。这种格式不适合包含孤立节点的网络。

文件edgelist.txt的内容可能如下:

0 1 {'weight': 4}
0 2 {'weight': 1}
0 3 {'weight': 2}
0 5 {'weight': 3}
1 2 {'weight': 5}

前两列是构成边的节点对,后续列可以包含边属性,例如权重。

使用read_edgelist函数加载此类图。data参数需要一个元组列表,来指定边数据的名称和类型。

G_edgelist = nx.read_edgelist("edgelist.txt", data=[('weight', int)], nodetype=int)

检查图的边,可以看到我们成功加载了图及其权重属性。请注意,在此示例中,节点的类型是字符串而非整数。不同的读取函数可能有不同的默认设置,使用时需小心。

从Pandas DataFrame创建图

如果你的网络数据需要进行任何分析或数据整理,从Pandas DataFrame创建图会非常有用。

DataFrame的结构需要类似于边列表格式。然后,我们可以使用from_pandas_edgelist函数,传入DataFrame、源节点列名、目标节点列名和边属性列名。

import pandas as pd
df = pd.DataFrame({
    'source': [0, 0, 0, 0, 1],
    'target': [1, 2, 3, 5, 2],
    'weight': [4, 1, 2, 3, 5]
})
G_df = nx.from_pandas_edgelist(df, 'source', 'target', edge_attr='weight')

检查其边,表明我们已成功从DataFrame加载了该图。

除了上述格式,NetworkX还内置了函数来轻松读取更结构化的格式,如GML或JSON。

实例分析:国际象棋对弈网络

现在,让我们通过一个分步示例,来学习如何读取和分析一个更复杂的网络。我们将使用边列表格式的国际象棋对弈网络。

首先,查看文件chess_graph.txt的前五行:

0 1 1 2018.01.01
1 2 -1 2018.01.02
2 0 0 2018.01.03
3 4 1 2018.01.04
4 5 -1 2018.01.05
  • 每个节点对应一位棋手。
  • 一条有向边代表一局对弈:第一列的白棋手有一条出边,第二列的黑棋手有一条入边。
  • 第三列的边权重代表结果:1表示白方赢,0表示平局,-1表示黑方赢。
  • 第四列包含对弈时间的大致时间戳。

我们使用read_edgelist函数读取这个网络,传入文件名和边属性的元组列表。同时,我们指定创建一个MultiDiGraph(有向多重图)。这一点很重要,因为如果使用默认的无向图,方向和额外的边信息将会丢失。

chess_graph = nx.read_edgelist("chess_graph.txt",
                               data=[('outcome', int), ('timestamp', str)],
                               create_using=nx.MultiDiGraph())

检查确认新图是有向多重图:

print(type(chess_graph))  # 应输出 <class 'networkx.classes.multidigraph.MultiDiGraph'>

现在,查看带属性的边,确认已成功导入国际象棋网络。

找出对局最多的棋手

通过查看每个节点的度(即连接的边数),我们可以找出网络中参与对局最多的棋手。degree函数返回一个字典,包含每个节点连接的边数。注意,这是一个有向网络,degree函数返回的是入度和出度之和。

degree_dict = dict(chess_graph.degree())
max_degree = max(degree_dict.values())
player_with_max_degree = [player for player, degree in degree_dict.items() if degree == max_degree]
print(f"对局最多的棋手是 {player_with_max_degree[0]},对局数为 {max_degree}。")

使用Pandas找出获胜最多的棋手

我们还可以使用Pandas进行更复杂的分析。首先,从图的边数据创建一个DataFrame。

edge_data = list(chess_graph.edges(data=True))
df_chess = pd.DataFrame(edge_data, columns=['white', 'black', 'attr_dict'])

DataFrame的outcome列包含边属性的字典。我们使用maplambda函数从中提取结果值。

df_chess['outcome'] = df_chess['attr_dict'].map(lambda x: x['outcome'])

接下来,找出每位棋手作为白方获胜的次数(outcome == 1)和作为黑方获胜的次数(outcome == -1)。

# 白方获胜
white_wins = df_chess[df_chess['outcome'] == 1].groupby('white').size()
# 黑方获胜(结果乘以-1后求和,得到正数)
black_wins = df_chess[df_chess['outcome'] == -1].groupby('black').size() * -1

为了计算总胜场数,我们将白方胜场和黑方胜场相加。使用add函数并设置fill_value=0,可以处理那些只作为黑方或白方参赛的棋手数据。

win_count = white_wins.add(black_wins, fill_value=0)

最后,使用nlargest函数找出获胜最多的棋手。

top_winners = win_count.nlargest(5)
print("获胜最多的棋手:")
print(top_winners)

总结

本节课中,我们一起学习了如何使用NetworkX加载不同格式的图数据。我们从邻接表、邻接矩阵、边列表等基本格式入手,并演示了如何从Pandas DataFrame创建图。最后,通过一个国际象棋对弈网络的实例,我们实践了读取复杂网络、检查图类型以及使用NetworkX和Pandas进行基本分析(如计算节点度和分析胜率)的完整流程。希望本教程能帮助你理解图的不同表示方法,并掌握将它们加载到NetworkX中的技能。

26:聚类系数分析 📊

在本节课中,我们将要学习网络分析中的一个重要概念——三元闭包,以及如何通过聚类系数来量化网络中节点形成三角形的倾向。我们将从单个节点的局部视角开始,逐步扩展到整个网络的全局度量,并使用Python的NetworkX库进行实际计算。

三元闭包与聚类

上一节我们介绍了网络的基本概念。本节中我们来看看三元闭包。三元闭包是指,拥有大量共同连接的人倾向于彼此之间也形成连接。例如,拥有许多共同朋友的人,他们自己成为朋友的可能性会增加。

虽然导致这一过程的机制有很多,但本节课的重点是学习如何在网络中测量它。

假设你有一个网络,并被问及哪些边最有可能在未来出现。三元闭包理论认为,那些能够“闭合三角形”的边是很好的候选边。在下图中,所有红色的边都能形成闭合的三角形,因此它们更有可能出现。

然而,我们并不总是拥有时间戳数据,或者知道边进入网络的顺序。有时我们只有一个静态网络,而我们想知道这个网络中是否存在三元闭包,即它是否包含许多三角形。因此,本视频将探讨如何测量网络中三元闭包的普遍程度。

三元闭包的另一种说法是聚类。我们今天要介绍的许多定义都与聚类有关。

局部聚类系数

我们将从测量聚类的局部版本开始,即从单个节点的视角进行测量,这被称为局部聚类系数。它的定义是:一个节点的朋友之间彼此也是朋友的比例。

展示局部聚类系数如何工作的最好方式是通过一个例子。假设你想计算节点C的聚类系数。

你需要做的是计算节点C的朋友中,彼此是朋友的对数,与节点C所有可能的朋友对总数之比。

节点C在这个网络中有四个朋友,这意味着节点C的度为4。度是指一个节点拥有的连接数,我们用 d_c 表示。在这里,d_c = 4

那么,节点C有多少对朋友呢?节点C有四个朋友。如果你有四个人,那么总共有六种可能的配对。因此,节点C的朋友对总数是6。当朋友数量较多时,直接计算可能比较困难。你可以使用以下公式来计算可能的朋友对数量:

d_c * (d_c - 1) / 2

在这个例子中,结果是 4 * 3 / 2 = 6。这就是分母。

分子是节点C的朋友中,彼此是朋友的对数。在这个网络中,只有两对朋友彼此是朋友:A-B 和 E-F。所以分子是2。

因此,节点C的局部聚类系数是 2 / 6 = 1/3。这意味着,在节点C所有可能成为朋友的朋友对中,有三分之一实际上已经是朋友。

让我们再计算节点F的局部聚类系数。同样,我们需要计算节点F的朋友中彼此是朋友的对数,与所有可能的朋友对总数之比。

节点F的度为3,所以可能的朋友对总数是 3 * 2 / 2 = 3
实际上,节点F的朋友中只有一对彼此是朋友:C和E。
因此,节点F的局部聚类系数也是 1/3

最后,计算节点J的局部聚类系数。节点J只有一个朋友,即节点I。这意味着节点J有零对朋友。由于分母不能为零,对于这种朋友数少于两个的节点,我们定义其局部聚类系数为0。这与NetworkX库的处理方式一致。

使用NetworkX计算局部聚类系数

了解了理论后,我们来看看如何在代码中实现。假设我们已经按照已知的方式加载了这个图(记为G)。

我们可以使用clustering函数来计算单个节点的局部聚类系数。

以下是计算示例:

# 计算节点F的局部聚类系数
clustering_coeff_F = nx.clustering(G, 'F')  # 结果约为 0.33
# 计算节点A的局部聚类系数
clustering_coeff_A = nx.clustering(G, 'A')  # 结果约为 0.66
# 计算节点J的局部聚类系数
clustering_coeff_J = nx.clustering(G, 'J')  # 结果为 0.0

这允许你计算图中每个节点的局部聚类系数。

全局聚类系数

我们最初的目标是判断三元闭包在整个网络中是否普遍存在。那么,如何从每个节点的局部度量,得到整个网络的全局度量呢?我们将讨论两种不同的方法。

第一种方法简单直接:计算图中所有节点局部聚类系数的平均值。

你可以在NetworkX中使用average_clustering函数来实现。

avg_clustering = nx.average_clustering(G)  # 在这个例子中,结果是 0.29

这是第一种全局度量方法。

第二种方法如下:我们尝试测量网络中“开放三元组”形成“三角形”的比例。

  • 三角形:由三条边完全连接的三个节点。
  • 开放三元组:只由两条边连接的三个节点。

需要注意的是,一个三角形实际上包含三个不同的开放三元组。例如,考虑一个三角形ABC(边AB, BC, CA)。它可以衍生出三个开放三元组:缺少边BC的(A,B,C)、缺少边CA的(A,B,C)、缺少边AB的(A,B,C)。

因此,在这种被称为“传递性”的第二种聚类系数测量方法中,我们计算:
(三角形数量 * 3) / 开放三元组数量

这代表了实际上形成三角形(即闭合三元组)的开放三元组的比例。

你可以在NetworkX中使用transitivity函数来获取网络的传递性。

trans = nx.transitivity(G)  # 在这个例子中,结果是 0.41

两种全局度量方法的比较

我们现在有两种测量全局聚类系数的方法。它们相同吗?哪个更好?两者之间存在差异。

它们都试图测量边形成三角形的倾向,但传递性会给连接数更多(度更高)的节点赋予更高的权重。通过例子可以最好地理解这一点。

例一:轮状图
这个图看起来像一个轮子。外围的大多数节点局部聚类系数都很高(为1),因为它们只有两个朋友,而这对朋友彼此相连。然而,中心的节点度很高,但局部聚类系数非常低,因为它的大多数朋友彼此之间并不相连。
在这个图中:

  • 平均聚类系数相当高(0.93),因为大多数节点系数很高。
  • 但传递性很低(0.23),因为它更重视那个高度数但低聚类系数的中心节点,从而拉低了整体分值。

例二:另一种结构
在这个网络中,大多数外围节点局部聚类系数为0(度数为1或2但朋友彼此不连),这样的节点有15个。内部少数节点(5个)度数高且局部聚类系数高。
在这个图中:

  • 平均聚类系数很低,因为大多数(15个)节点系数为0。
  • 但传递性较高,因为那些高度数的节点恰好具有高聚类系数,而传递性更重视这些节点。

这两个图展示了平均局部聚类系数和传递性之间的区别:传递性更重视高度数节点。

总结

本节课中我们一起学习了聚类系数,它衡量了网络中节点倾向于聚类或形成三角形的程度。

有几种方法可以测量这一点:

  1. 局部聚类系数:基于单个节点进行测量。其定义为:一个节点的朋友中,彼此是朋友的对数占所有可能朋友对的比例。例如,节点C的局部聚类系数为1/3。
  2. 全局聚类系数:衡量整个网络层面的聚类程度。
    • 第一种方法(平均聚类):计算所有节点局部聚类系数的平均值。在NetworkX中可使用 average_clustering 函数。
    • 第二种方法(传递性):计算网络中三角形数量与开放三元组数量的比值(3 * 三角形数 / 开放三元组数)。与平均聚类相比,它给高度数节点赋予了更大的权重。在NetworkX中可使用 transitivity 函数。

理解这些差异有助于你根据分析的具体网络和问题,选择合适的度量指标。

27:网络距离度量 📏

在本节课中,我们将学习社交网络中“距离”的概念。我们将探讨如何量化网络中节点之间的远近关系,并介绍一系列用于描述网络整体距离特性的度量指标。


路径与最短路径

上一节我们引入了网络的基本概念,本节中我们来看看如何定义节点间的距离。要定义距离,首先需要理解“路径”的概念。

路径是指由边连接起来的一系列节点序列。例如,从节点G到节点C可以找到路径 G -> F -> C

对于一条路径,我们定义其路径长度为从起点到终点所经过的“跳数”。例如,路径 A -> B -> C -> E -> H 的长度为4。

两个节点之间的距离,则定义为它们之间所有可能路径中最短路径的长度。因此,节点A到节点H的距离是4。

在 NetworkX 中,可以使用以下函数计算:

# 查找最短路径
nx.shortest_path(G, source='A', target='H')
# 计算最短路径长度
nx.shortest_path_length(G, source='A', target='H')

广度优先搜索算法

当我们需要计算从一个节点到网络中所有其他节点的距离时,手动计算在大型网络中会非常繁琐。此时,我们可以使用广度优先搜索算法来高效地完成此任务。

该算法从一个起始节点(例如节点A)开始,逐层“发现”网络中的节点:

  1. 第0层:起始节点A(距离为0)。
  2. 第1层:发现与A直接相连的节点B和K(距离为1)。
  3. 第2层:处理B和K,发现它们未发现的邻居节点C(距离为2)。
  4. 依此类推,直到所有节点都被发现并分配了距离。

在 NetworkX 中,可以使用 nx.bfs_tree(G, source='A') 函数来获取广度优先搜索生成的树结构。若只需距离信息,可使用 nx.shortest_path_length(G, source='A') 获取一个包含所有距离的字典。


网络层面的距离度量

了解了节点对之间的距离后,我们可以从整体上描述网络的距离特性。以下是几个关键的度量指标:

平均路径长度:网络中所有节点对之间距离的平均值。它反映了节点间联系的紧密程度。

nx.average_shortest_path_length(G)

直径:网络中任意两个节点之间距离的最大值。它代表了网络的“跨度”。

nx.diameter(G)

节点离心率:一个节点到网络中所有其他节点距离的最大值。它衡量了该节点的“偏远”程度。

nx.eccentricity(G)

网络半径:所有节点离心率中的最小值。它代表了网络中最核心节点到其他节点的最远距离。

nx.radius(G)

网络中心与外围

基于上述度量,我们可以识别出网络中处于核心或外围位置的节点。

网络外围:离心率等于网络直径的节点集合。这些节点通常位于网络的边缘。

nx.periphery(G)

网络中心:离心率等于网络半径的节点集合。这些节点到网络中其他节点的最大距离最小,通常处于核心位置。

nx.center(G)

案例分析:空手道俱乐部网络

让我们将所学概念应用到一个真实网络——空手道俱乐部友谊网络中。该网络记录了俱乐部成员间的友谊关系,其中节点1是教练,节点34是助理,两人因分歧导致俱乐部最终分裂。

加载并分析该网络:

G = nx.karate_club_graph()

分析结果如下:

  • 平均路径长度:约2.41
  • 半径:3
  • 直径:5
  • 网络中心:包括教练(节点1)及其一些高度连接的学生。
  • 网络外围:一些连接较少的节点,且均未与教练直接相连。

值得注意的是,助理(节点34)虽然连接较多,但因与节点17的距离为4(大于网络半径3),而未能被归入中心节点。这说明了基于离心率的中心性定义可能对网络中微小的距离变化非常敏感。


总结

本节课中我们一起学习了网络距离度量的核心概念:

  1. 节点对层面:定义了节点间距离(最短路径长度)和节点离心率(到所有其他节点的最大距离)。
  2. 网络整体层面:引入了平均路径长度直径(最大距离)和半径(最小离心率)来描述网络全局的距离特征。
  3. 节点位置识别:利用上述概念定义了网络外围(离心率=直径的节点集)和网络中心(离心率=半径的节点集)。

通过这些工具,我们可以量化分析社交网络中节点间的远近关系,并识别出处于核心或边缘位置的关键节点。

28:连通组件分析

概述

在本节课中,我们将要学习网络中的连通性概念。我们将探讨无向图和有向图中的连通性定义,并学习如何使用NetworkX库来分析和识别图中的连通组件。


无向图的连通性

上一节我们介绍了网络分析的基本概念,本节中我们来看看无向图的连通性。

一个无向图被称为连通的,如果对于图中的每一对节点,都存在一条连接这两个节点的路径。

我们可以使用NetworkX中的 is_connected 函数,并输入无向图作为参数,它会告诉我们该图是否连通。

import networkx as nx
is_connected = nx.is_connected(G)

在这个例子中,该图是连通的,因此函数返回 True。然而,如果我们移除一些边,例如边AG、AN和JO,那么图就会变得不连通。

现在,我们有了三个类似社区的节点集合。如果你在其中一个集合中,你无法找到一条路径到达另一个不同集合中的节点。


连通组件的定义

为了更精确地描述这些“社区”的概念,我们将它们称为连通组件。以下是连通组件的定义:

一个连通组件是一个节点的子集,它满足两个条件:

  1. 该子集中的每个节点都必须有一条路径到达子集中的其他每个节点。
  2. 子集之外的任何节点都没有路径到达子集内的任何节点。

条件2确保你得到了所有可能得到的节点,使得子集中的每个节点都与子集中的其他节点相连,而不是一个可能得到的子集的子集。

让我们通过例子来更清楚地理解。

示例1: 节点子集 {E, A, G, F} 是一个连通组件吗?
首先,我们找到这些节点。我们可以清楚地看到,这不是一个连通组件,因为例如节点A和F无法相互到达,它们之间没有路径。因此,条件1不满足。

示例2: 节点子集 {N, O, K} 是一个连通组件吗?
在这种情况下,条件1实际上得到了满足。从N、O、K中的任何一个节点到其他节点都存在路径。然而,条件2不满足,因为存在其他节点可以到达这个子集中的节点。例如,节点L可以到达N、O和K。因此,这不是一个连通组件,因为条件2不满足。

所以,唯一满足连通组件定义的就是我们最初看到的三个“社区”,其中每个节点在内部相连,且没有节点跨社区相连。这些就是这个特定图中的三个连通组件。


使用NetworkX查找连通组件

以下是使用NetworkX处理连通组件的相关函数:

  • number_connected_components(G):返回图中连通组件的数量。
    num_components = nx.number_connected_components(G)
    # 在这个例子中,返回 3
    
  • connected_components(G):返回图中所有连通组件(节点集合的生成器)。
    components = list(nx.connected_components(G))
    # 返回类似 [{'A', 'B', 'C'}, {'D', 'E'}, {'F', 'G', 'H'}] 的列表
    
  • node_connected_component(G, node):返回指定节点所属的连通组件。
    component_of_M = nx.node_connected_component(G, 'M')
    # 返回节点M所属的组件,例如 {'K', 'L', 'M', 'O'}
    

有向图的连通性

上一节我们讨论了无向图的连通性,本节中我们来看看有向图的情况。

有向图是边具有方向的图,每条边都有源节点和目标节点。由于路径在有向图中的定义与无向图不同,我们需要相应地调整我们的定义。实际上,对于“连通”和“连通组件”这两个概念,我们各有两种定义。

强连通与弱连通

让我们从“连通”的概念开始。

  1. 强连通:如果一个有向图中,对于每一对节点U和V,都存在一条从U到V的有向路径,同时也存在一条从V到U的有向路径,那么这个图就是强连通的。
    我们可以使用 is_strongly_connected 函数来检查。

    is_strongly_conn = nx.is_strongly_connected(G_directed)
    # 对于示例图,返回 False
    

    例如,在示例图中,没有从节点A到节点H的路径,因此该图不是强连通的。

  2. 弱连通:判断一个有向图是否弱连通的方法是:首先,忽略图中所有有向边的方向,将它们视为无向边,从而得到一个无向图。然后,判断这个新的无向图是否连通。如果连通,则原图是弱连通的。
    我们可以使用 is_weakly_connected 函数来检查。

    is_weakly_conn = nx.is_weakly_connected(G_directed)
    # 对于示例图,返回 True
    

强连通组件与弱连通组件

现在,让我们讨论连通组件的定义,同样有两种类型。

  1. 强连通组件:其定义与无向图类似,只是现在路径必须是有向的。对于一个节点子集,要成为强连通组件,需要满足:

    • 子集中的每个节点都有一条有向路径到达子集中的其他每个节点。
    • 子集之外的任何节点都没有有向路径到达或来自子集内的每个节点。
      我们可以使用 strongly_connected_components 函数来查找。
    strong_components = list(nx.strongly_connected_components(G_directed))
    

    在示例图中,节点M和节点L属于不同的强连通组件,因为虽然可以从L到M,但不能从M到L。

  2. 弱连通组件:其工作方式与之前相同。首先,将所有有向边视为无向边,然后在新的无向图中查找连通组件。由于示例图是弱连通的,这意味着当你将所有有向边无向化后,它变成了一个连通图。因此,这个特定的图只有一个弱连通组件,即整个图。
    我们可以使用 weakly_connected_components 函数来查找。

    weak_components = list(nx.weakly_connected_components(G_directed))
    

总结

本节课中我们一起学习了无向图和有向图中的连通性概念。

  • 对于无向图,我们说一个无向图是连通的,如果对于每一对节点,它们之间都存在一条路径。我们讨论了连通组件,并介绍了如何使用 connected_components 函数来查找这些组件。
  • 对于有向图,我们有两种定义:强连通弱连通
    • 一个图是强连通的,如果每一对节点之间都存在双向的有向路径。可以使用 strongly_connected_components 函数查找强连通组件。
    • 弱连通及其对应的弱连通组件的定义,是通过将有向边无向化,然后将无向图的定义应用到新得到的无向图上来实现的。

感谢观看,我们下次再见。

28:网络鲁棒性研究 🔗

在本节课中,我们将学习网络鲁棒性的概念,探讨网络在面临节点或边失效时,维持其连通性和功能的能力。我们将通过具体的函数和示例,理解如何量化网络的鲁棒性。


概述

上一节我们讨论了网络的连通性。本节中,我们将探讨连通性如何与网络的鲁棒性相关联。网络鲁棒性是指网络在面对节点或边失效(无论是随机故障还是蓄意攻击)时,维持其基本结构和功能的能力。这对于许多现实世界的系统(如航空网络、通信网络)至关重要。


什么是网络鲁棒性?

网络鲁棒性可以宽泛地定义为:当网络面临故障或攻击时,维持其基本结构或功能的能力。

我们在此讨论的攻击形式是节点或边的移除。这可能是有人蓄意移除网络中的节点或边,也可能是网络自身发生的随机故障。

我们将讨论的核心属性是连通性。因此,鲁棒性就是网络在失去部分节点或边时,维持其连通性的能力。


为什么网络鲁棒性很重要?

以下是一些经常面临节点或边失效的网络示例,这些失效会影响其功能:

  • 航空运输网络:节点是机场,边是机场间的航线。有时机场会因各种原因关闭,此时运输会受到影响。我们希望运输网络对机场关闭具有鲁棒性,这样即使失去某个特定机场,网络的基本连通性和功能仍能维持。
  • 路由器间故障或电力线故障:在这些网络中,移除节点或边的情况确实会发生。对于所有这些系统,维持连通性都至关重要。

分析无向图的鲁棒性

让我们从一个简单的无向图示例开始分析。

节点连通性

首先,我们提出一个问题:为了使这个图变得不连通,我需要移除的最少节点数量是多少?

我们可以使用 NetworkX 库中的 node_connectivity 函数。输入是无向图 G

node_connectivity(G)

该函数会返回一个数字,表示需要移除的最少节点数。例如,如果结果是 1,意味着只需移除一个节点就能使图完全不连通。

为了找出具体是哪个节点,可以使用 minimum_node_cut 函数。

minimum_node_cut(G)

该函数会返回一组节点,移除这些节点可以使图断开。例如,它可能返回节点 A。移除节点 A 后,图会从一个连通图变为两个无法相互到达的节点子集。

在航空网络的例子中,如果一个机场负责连接世界的主要部分,这种情况是非常不理想的。

边连通性

现在,我们针对边提出同样的问题:为了使这个图变得不连通,我需要移除的最少边数量是多少?

我们可以使用 edge_connectivity 函数。

edge_connectivity(G)

该函数会返回一个数字,例如 2,表示需要移除至少两条边才能断开图。

为了找出具体是哪些边,可以使用 minimum_edge_cut 函数。

minimum_edge_cut(G)

该函数会返回一组边,例如边 AGOJ。移除这两条边后,网络确实会变得不连通,形成两个无法相互通信的节点簇。可能还有其他边组合能达到同样效果,但 edge_connectivity 的结果 2 告诉我们,必须移除的最少边数是 2。

鲁棒的网络是指那些具有较大最小节点割和边割的网络。也就是说,你需要移除很多节点或边才能断开它们。这是一个非常理想的属性,因为你不希望你的网络仅因移除少数几个节点或边就轻易断开。


分析有向图的鲁棒性

现在,让我们看一个不同的场景。首先,我们来看一个有向图,其中的边具有源节点和目标节点。

我们设想一个不同的场景:将这些节点视为需要相互通信的实体(例如路由器或传递消息的人)。假设节点 G 想通过网络中的其他节点向节点 L 发送一条消息。

寻找所有路径

G 有哪些路径可以选择呢?我们可以使用 all_simple_paths 函数来找出从源节点 G 到目标节点 L 的所有可能路径。

list(all_simple_paths(G, source='G', target='L'))

函数会返回所有路径,例如:

  • G -> A -> L
  • G -> A -> N -> O -> K -> L
  • G -> A -> N -> O -> L
  • G -> J -> O -> K -> L
  • G -> J -> O -> L

针对特定通信的鲁棒性

现在,假设有一个攻击者,他不想断开整个网络,而是专门想阻断从 G 到 L 的通信

我们可以问:如果这个攻击者通过移除节点来实现,他需要移除多少个节点才能阻断 G 到 L 的通信?

我们可以使用之前用过的 node_connectivity 函数,但这次额外指定源节点和目标节点。

node_connectivity(G, s='G', t='L')

假设结果是 2,这意味着需要移除至少两个节点。

要找出具体是哪两个节点,可以使用 minimum_node_cut 函数并指定源和目标。

minimum_node_cut(G, s='G', t='L')

假设它返回节点 NO。我们可以验证:只移除 N,路径 G->J->O->L 仍然存在;只移除 O,路径 G->A->L 仍然存在。只有同时移除 N 和 O,才没有任何路径能从 G 到达 L。

针对边的攻击

同样,如果攻击者只能移除边,他需要移除多少条边才能阻断 G 到 L 的通信?我们可以使用 edge_connectivity 函数并指定源和目标。

edge_connectivity(G, s='G', t='L')

假设结果是 2。要找出具体是哪两条边,可以使用 minimum_edge_cut 函数。

minimum_edge_cut(G, s='G', t='L')

假设它返回边 A->NJ->O。移除这两条边(图中标红)后,G 将无法再发送消息到 L。


总结

本节课中,我们一起学习了网络鲁棒性的核心概念:

  1. 节点连通性:为断开一个图(或断开一对特定节点)所需移除的最少节点数
    • 相关函数:node_connectivity(计算最小数量),minimum_node_cut(找出具体节点)。
  2. 边连通性:为断开一个图(或断开一对特定节点)所需移除的最少边数
    • 相关函数:edge_connectivity(计算最小数量),minimum_edge_cut(找出具体边)。

关键要点:具有较高节点连通性和边连通性的图,对节点和边的丢失具有更强的鲁棒性。即使网络中有一定数量的边和节点被移除,它们仍能保持连通性。对于许多应用而言,这是网络非常理想的一个属性。

感谢观看,我们下次再见!😊

30:NetworkX基础网络可视化 🕸️

在本节课中,我们将学习如何使用NetworkX库进行基础的网络可视化。虽然NetworkX并非专业的绘图工具包,但它集成了Matplotlib,提供了绘制网络图的基本功能,帮助我们观察和探索网络结构。

读取与绘制基础网络图

首先,我们从一个Python pickle文件中读取一个网络图。这个网络代表了一个包含51个美国城市的配送网络。每个节点(城市)有两个属性:location(位置)和population(人口)。每条边代表配送网络中的一条连接,其weight属性代表通过该连接运输的成本。

import networkx as nx
import matplotlib.pyplot as plt

# 读取图数据
G = nx.read_gpickle('graph.pickle')

# 创建图形并绘制网络
plt.figure()
nx.draw(G)
plt.show()

以上代码会使用默认设置(红色圆形节点、黑色边线)绘制网络图。节点布局为我们提供了网络结构的初步洞察。

探索不同的节点布局算法

上一节我们介绍了基础的绘图方法,本节中我们来看看如何优化节点布局。网络可视化最大的挑战之一是确定节点的位置。NetworkX提供了多种布局算法来帮助创建可视化布局。

例如,draw函数默认使用spring_layout(弹簧布局),它试图在保持边长相似的同时,尽量减少边的交叉。

以下是NetworkX提供的一些布局函数:

  • nx.spring_layout: 弹簧布局,基于力导向算法。
  • nx.circular_layout: 环形布局,将节点均匀排列在一个圆上。
  • nx.random_layout: 随机布局,在单位正方形内随机放置节点。
  • nx.shell_layout: 壳形布局,将节点排列在多个同心圆上。

选择哪种布局很大程度上取决于网络本身的特性,因此在创建可视化时尝试多种布局非常重要。

# 尝试不同的布局
pos_circular = nx.circular_layout(G)  # 返回节点位置字典
nx.draw(G, pos=pos_circular)
plt.show()

使用节点属性自定义布局

我们也可以使用自定义的节点位置字典。我们的配送网络节点恰好有location属性,我们可以直接用它来定位。

# 从节点属性中提取位置信息
pos = {node: data['location'] for node, data in G.nodes(data=True)}
nx.draw(G, pos=pos)
plt.show()

如果你熟悉美国城市的地理位置,现在应该能更好地识别这个网络了。

调整可视化样式参数

接下来,我们尝试调整一些绘图参数,以改变可视化的外观,使其更清晰。

以下是可调整的部分参数:

  • alpha: 设置节点和边的透明度。
  • with_labels: 控制是否显示节点标签。
  • edge_color: 设置边的颜色。
  • ax: 控制是否显示坐标轴。
nx.draw(G, pos=pos, alpha=0.7, with_labels=False, edge_color='gray')
plt.axis('off')  # 关闭坐标轴
plt.tight_layout()  # 减少边距
plt.show()

通过移除部分信息(如标签和坐标轴),网络的结构变得更加清晰易见。

基于网络属性设置视觉编码

现在,我们来看看如何根据网络属性(如度、人口、成本)来动态设置节点颜色、大小和边宽,以编码更多信息。

# 1. 根据节点度设置节点颜色
node_degree = [G.degree(n) for n in G.nodes()]
# 2. 根据人口设置节点大小
node_size = [G.nodes[n]['population'] * 0.01 for n in G.nodes()]
# 3. 根据成本(权重)设置边宽
edge_width = [G[u][v]['weight'] * 0.05 for u, v in G.edges()]

nx.draw(G, pos=pos,
        node_color=node_degree,
        node_size=node_size,
        width=edge_width,
        cmap=plt.cm.Blues,  # 使用蓝色系颜色映射
        with_labels=False,
        edge_color='lightgray')
plt.show()

通过这个可视化,我们可以快速识别出配送网络中人口密集(节点大)或连接度高(节点颜色深)的城市。

单独绘制边、节点和标签

最后,我们将学习如何分别绘制边、节点和标签。这在需要高亮显示特定网络元素时非常有用。

假设我们想高亮显示网络中成本最高(权重大于770)的边,并为洛杉矶和纽约市添加标签。

# 找出成本大于770的边
high_cost_edges = [(u, v) for u, v, d in G.edges(data=True) if d['weight'] > 770]

# 先绘制基础网络(灰色细边)
nx.draw_networkx_edges(G, pos, edge_color='lightgray', width=1)
nx.draw_networkx_nodes(G, pos, node_color='lightblue', node_size=50)

# 再高亮绘制高成本边(红色粗边)
nx.draw_networkx_edges(G, pos, edgelist=high_cost_edges, edge_color='red', width=4, alpha=0.7)

# 为特定城市添加标签
labels = {'Los Angeles': 'LA', 'New York City': 'NYC'}
nx.draw_networkx_labels(G, pos, labels=labels, font_size=12, font_color='darkred')

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/umich-app-ds-py/img/dccc2c77bd10ae31b5203b92eeb7e21c_5.png)

plt.axis('off')
plt.tight_layout()
plt.show()

通过分别添加特定的边和标签,我们现在可以清晰地看到高成本连接以及纽约和洛杉矶的位置。

总结与延伸

本节课中我们一起学习了使用NetworkX进行网络可视化的核心技能。我们涵盖了从基础绘图、尝试不同布局、使用属性自定义位置,到根据网络属性设置视觉编码(颜色、大小、宽度),以及分别绘制网络元素以进行高亮展示。

我们介绍了NetworkX提供的主要绘图工具,但请记住,你始终可以查阅NetworkX官方文档以获取绘图函数及其参数的更多信息。

如果你有兴趣为更大型的网络创建更深入的可视化,可以考虑探索功能更全面的专业可视化工具,如 CytoscapeGephiGraphviz

31:度中心性与接近中心性 📊

在本节课中,我们将学习如何在网络中识别重要的节点。我们将介绍两种衡量节点重要性的方法:度中心性和接近中心性。这两种方法基于不同的假设,帮助我们理解网络中哪些节点扮演着关键角色。

概述

网络分析中的一个核心问题是识别网络中的重要节点。例如,在一个社交网络中,我们可能想知道哪些人是信息传播的关键人物。本节课将介绍两种常用的中心性度量方法:度中心性和接近中心性,并通过具体示例和代码演示如何计算它们。

识别重要节点

上一节我们介绍了网络的基本概念,本节中我们来看看如何衡量节点的重要性。考虑一个由34人组成的空手道俱乐部友谊网络。基于网络结构,你认为哪五个节点最重要?这个问题有多种思考方式。

度中心性

一种方法是认为拥有许多连接的节点是重要的。如果我们使用这个定义,那么最重要的五个节点是节点34、1、33、3和2。度中心性假设重要的节点拥有许多邻居或朋友。

在无向网络中,我们直接使用节点的度。在有向网络中,我们可以选择入度、出度或两者的组合。度中心性的计算公式如下:

公式:
C_degree(v) = degree(v) / (n - 1)

其中,degree(v) 是节点 v 的度,n 是网络中的节点总数。这个值介于0和1之间,1表示节点连接到网络中的所有其他节点。

以下是使用 NetworkX 计算度中心性的代码示例:

import networkx as nx

# 加载空手道俱乐部图
G = nx.karate_club_graph()
# 计算所有节点的度中心性
degree_centrality = nx.degree_centrality(G)
# 查看节点34的度中心性
print(degree_centrality[34])  # 输出: 0.515

节点34的度中心性是0.515,因为它有17个连接,而网络中有34个节点,所以计算为 17 / 33

对于有向网络,我们可以分别计算入度中心性和出度中心性:

# 假设 G_directed 是一个有向图
in_degree_centrality = nx.in_degree_centrality(G_directed)
out_degree_centrality = nx.out_degree_centrality(G_directed)

接近中心性

另一种衡量重要性的方法是认为重要的节点距离网络中的所有其他节点都很近。接近中心性基于节点到其他所有节点的最短路径长度之和。

接近中心性的计算公式如下:

公式:
C_closeness(v) = (n - 1) / (∑_{u ≠ v} d(v, u))

其中,d(v, u) 是节点 v 到节点 u 的最短路径长度。这个值越大,表示节点越接近网络中心。

以下是使用 NetworkX 计算接近中心性的代码示例:

# 计算所有节点的接近中心性
closeness_centrality = nx.closeness_centrality(G)
# 查看节点32的接近中心性
print(closeness_centrality[32])  # 输出: 0.541

节点32的接近中心性是0.541。我们可以验证这个计算:首先计算节点32到所有其他节点的最短路径长度之和,假设和为61,那么接近中心性为 (34 - 1) / 61 ≈ 0.541

处理不连通网络

在现实网络中,并非所有节点都能相互到达。例如,在有向图或存在多个连通分量的无向图中,某些节点可能无法到达其他节点。在这种情况下,我们需要调整接近中心性的计算。

以下是两种处理方式:

  1. 仅考虑可达节点:只计算节点能够到达的那些节点的距离之和。但这种方法可能导致只能到达少数节点的节点具有很高的中心性,这不太合理。
  2. 归一化处理:在考虑可达节点的同时,乘以节点能够到达的节点比例。这样,只能到达少数节点的节点中心性会较低。

NetworkX 中的 closeness_centrality 函数提供了归一化选项:

# 不归一化
closeness_no_norm = nx.closeness_centrality(G, normalized=False)
# 归一化(默认)
closeness_norm = nx.closeness_centrality(G, normalized=True)

总结

本节课中我们一起学习了两种中心性度量方法:度中心性和接近中心性。度中心性基于节点拥有的连接数量,适用于快速识别高度连接的节点。接近中心性基于节点到其他节点的距离,适用于识别网络中的核心节点。这两种方法在不同类型的网络分析中都有广泛应用,帮助我们理解网络结构和节点重要性。

通过本节课的学习,你应该能够使用 NetworkX 计算这两种中心性,并根据具体网络选择合适的方法。下一节课我们将继续探讨其他中心性度量方法。

32:中介中心性分析 📊

在本节课中,我们将学习网络中另一种衡量中心性的方法——中介中心性。该方法基于一个核心假设:网络中重要的节点是那些连接其他节点的节点。

核心概念与定义

上一节我们介绍了中心性的概念,本节中我们来看看中介中心性的具体定义。首先,两个节点之间的距离是指它们之间最短路径的长度。例如,在一个网络中,节点34和节点2之间的距离是2,因为存在多条两步即可从节点34到达节点2的路径。

中介中心性关注的问题是:哪些节点会频繁出现在不同节点对之间的最短路径上?为了量化这一点,我们为节点V定义中介中心性。

以下是其计算公式:

C_B(v) = Σ (σ_st(v) / σ_st)

其中:

  • st 是网络中除 v 外的任意节点对。
  • σ_st 是节点 s 到节点 t 的最短路径总数。
  • σ_st(v) 是节点 s 到节点 t 且经过节点 v 的最短路径数。

核心思想是: 如果一个节点 v 出现在许多其他节点对的最短路径中,那么它就具有较高的中介中心性。

计算中的关键选项

在具体计算时,我们需要考虑几个选项,这会影响最终结果。

是否包含节点自身作为端点

计算节点V的中介中心性时,一个关键选择是:是否允许节点V本身作为路径的起点(s)或终点(t)。

  • 排除V作为端点: 此时只计算其他节点对(s, t)之间的最短路径,并检查V是否出现在这些路径中。这种方法下,V的中心性仅源于其作为“桥梁”连接其他节点的能力。
  • 包含V作为端点: 此时计算的所有节点对(s, t)中,s或t可以是V本身。这通常会导致V的中心性值更高,因为它与自身之间的路径(距离为0)总是经过自己。

处理不连通节点对

在图中,特别是有向图中,可能存在节点对之间没有路径可达的情况。此时,σ_st = 0,会导致公式中的分母为零,计算无意义。

解决方法: 在求和时,我们只考虑那些至少存在一条最短路径的节点对(s, t)。对于无法互通的节点对,我们直接忽略,不将其纳入计算。

中心性的归一化

直接计算的中介中心性存在一个问题:节点数多的网络,其节点可能仅仅因为潜在的节点对(s, t)更多而获得更高的中心性值。为了在不同规模的网络间进行比较,我们需要进行归一化。

归一化的方法是:将节点的中介中心性除以其所在网络中可能的节点对数量(计算时排除节点自身)。

  • 对于无向图,归一化除数为:(n-1)*(n-2)/2
  • 对于有向图,由于每条边有方向,可能的节点对数量是无向图的两倍,因此归一化除数为:(n-1)*(n-2)

在NetworkX中的实现

NetworkX库提供了便捷的函数来计算中介中心性。

以下是使用 betweenness_centrality 函数的基本代码示例:

import networkx as nx

# 假设G是一个NetworkX图对象
# 计算所有节点的中介中心性,不归一化,且排除节点自身作为端点
bc = nx.betweenness_centrality(G, normalized=False, endpoints=False)
print(bc)

# 找出中心性最高的5个节点
top_nodes = sorted(bc, key=bc.get, reverse=True)[:5]
print("Top 5 nodes by betweenness centrality:", top_nodes)

该函数支持我们讨论过的参数,如 normalized(是否归一化)和 endpoints(是否包含节点自身作为端点)。

近似计算与子集中心性

近似计算

对于大型网络,精确计算所有节点对的最短路径非常耗时(时间复杂度可达O(n³))。此时,我们可以采用近似计算

方法是:不遍历所有节点作为源节点(s),而是只随机抽取 k 个节点作为源节点集,基于这些源节点到所有目标节点(t)的最短路径来计算中介中心性。在NetworkX中,通过设置 betweenness_centrality 函数的 k 参数来实现。

# 使用k=10个节点进行近似计算
bc_approx = nx.betweenness_centrality(G, k=10)

子集中介中心性

有时,我们只关心网络中特定子集之间的连接。例如,我们想知道哪些节点在连接“源节点组”和“目标节点组”时最为关键。

NetworkX提供了 betweenness_centrality_subset 函数来实现此功能。

# 定义源节点集合和目标节点集合
sources = [1, 5, 10]
targets = [20, 25, 30]

# 计算基于特定子集的中介中心性
bc_subset = nx.betweenness_centrality_subset(G, sources=sources, targets=targets)

边的中介中心性

中介中心性的概念不仅可以应用于节点,同样可以应用于。边的中介中心性衡量了一条边出现在其他节点对最短路径中的频繁程度。

其定义与节点类似:
C_B(e) = Σ (σ_st(e) / σ_st)
其中 σ_st(e) 是经过边 e 的最短路径数。

在NetworkX中,使用 edge_betweenness_centrality 函数计算。

# 计算所有边的中介中心性
ebc = nx.edge_betweenness_centrality(G)

# 同样,也可以计算基于子集的边的中介中心性
ebc_subset = nx.edge_betweenness_centrality_subset(G, sources=sources, targets=targets)

总结

本节课中我们一起学习了中介中心性分析。我们了解到:

  1. 核心假设:中介中心性认为,网络中重要的节点是那些连接其他节点、充当“桥梁”的节点。
  2. 计算方法:通过统计一个节点出现在所有其他节点对最短路径中的比例来量化其重要性。
  3. 关键选项:计算时需注意是否包含节点自身作为端点,并妥善处理不连通的节点对。
  4. 归一化:为了在不同规模的网络间进行比较,需要对中心性值进行归一化处理。
  5. 实用技巧:对于大型网络,可以使用近似算法(抽样)来高效计算。还可以针对特定的源节点组和目标节点组计算子集中心性,以分析局部连接重要性。
  6. 边的中心性:中介中心性的概念可以自然地扩展到边上,用于识别网络中关键的联系通道。

通过掌握中介中心性,我们能够从“信息流控制”或“连接枢纽”的角度,更深入地识别和理解复杂网络中的关键元素。

33:基础PageRank算法 📊

在本节课中,我们将学习如何衡量网络中节点的重要性或中心性。我们将介绍一种名为PageRank的算法,该算法由谷歌创始人开发,用于通过网页的超链接网络结构来衡量网页的重要性。

PageRank的基本思想是为每个节点分配一个重要性分数。其核心假设是:重要的节点是那些被其他重要节点大量链接指向的节点。虽然PageRank可用于任何类型的网络(如网页或社交网络),但它尤其适用于具有有向边的网络,例如表示“谁给谁发送了电子邮件”的社交网络。

听起来这个定义有些循环:要计算一个节点的PageRank,需要知道指向它的节点的PageRank,而这些值最初是未知的。在本节中,我们将逐步讲解如何实际计算PageRank。

算法设置与初始化

首先,我们有一个包含N个节点的网络。PageRank的计算将逐步进行。所有节点的PageRank总和始终保持为常数1。初始时,每个节点的PageRank值被设置为 1/N

以下是算法的核心更新规则:

  1. 每个节点将其当前的PageRank值平均分配给所有它指向的节点。
  2. 每个节点新的PageRank值,就是所有指向它的节点所给予的PageRank值的总和。

我们将重复执行这个基本更新规则K次。

逐步计算示例

让我们通过一个具体例子来理解这个过程。考虑以下包含5个节点(A, B, C, D, E)的网络。在开始计算前,可以先思考一下,你认为哪个节点最重要?

我们将计算经过两步(K=2)迭代后每个节点的PageRank值。

第一步:初始化与计算 (K=1)

首先,初始化每个节点的PageRank为 1/5

现在,我们应用更新规则。请注意:在每一步计算中,我们总是使用上一步的PageRank值,而不是本轮已更新的值。

以下是每个节点新PageRank值的计算过程:

  • 节点A:接收来自D和E的PageRank。

    • 来自D:D指向3个节点(C, A, E),因此A获得D当前PageRank(1/5)的1/3,即 (1/3) * (1/5) = 1/15
    • 来自E:E只指向A,因此A获得E所有的PageRank,即 1/5
    • A的新PageRank总和:1/15 + 1/5 = 4/15
  • 节点B:接收来自A和C的PageRank。

    • 来自A:A指向B,因此B获得A所有的PageRank,即 1/5
    • 来自C:C只指向B,因此B获得C所有的PageRank,即 1/5
    • B的新PageRank总和:1/5 + 1/5 = 2/5
  • 节点C:接收来自B和D的PageRank。

    • 来自D:D指向3个节点,因此C获得D PageRank的1/3,即 (1/3) * (1/5) = 1/15
    • 来自B:B指向2个节点(C, D),因此C获得B PageRank的1/2,即 (1/2) * (1/5) = 1/10
    • C的新PageRank总和:1/15 + 1/10 = 5/30 = 1/6
  • 节点D:只接收来自B的PageRank。

    • 来自B:B指向2个节点,因此D获得B PageRank的1/2,即 (1/2) * (1/5) = 1/10
    • D的新PageRank:1/10
  • 节点E:只接收来自D的PageRank。

    • 来自D:D指向3个节点,因此E获得D PageRank的1/3,即 (1/3) * (1/5) = 1/15
    • E的新PageRank:1/15

至此,我们完成了第一步(K=1)的计算。这些新值将成为下一步计算的“旧值”。

第二步:继续迭代 (K=2)

现在,我们使用第一步得到的结果作为起点,再次应用相同的更新规则。

  • 节点A:接收来自D和E。

    • 来自D:D当前PageRank为1/10,指向3个节点。A获得 (1/3) * (1/10) = 1/30
    • 来自E:E当前PageRank为1/15,只指向A。A获得 1/15
    • A的新PageRank总和:1/30 + 1/15 = 1/10
  • 节点B:接收来自A和C。

    • 来自C:C当前PageRank为1/6,只指向B。B获得 1/6
    • 来自A:A当前PageRank为4/15,只指向B。B获得 4/15
    • B的新PageRank总和:1/6 + 4/15 = 13/30 ≈ 0.433
  • 节点C:接收来自B和D。

    • 来自D:D当前PageRank为1/10。C获得 (1/3) * (1/10) = 1/30
    • 来自B:B当前PageRank为2/5,指向2个节点。C获得 (1/2) * (2/5) = 1/5
    • C的新PageRank总和:1/30 + 1/5 = 7/30 ≈ 0.233
  • 节点D:只接收来自B。

    • 来自B:B当前PageRank为2/5,指向2个节点。D获得 (1/2) * (2/5) = 1/5
    • D的新PageRank:1/5 = 0.2
  • 节点E:只接收来自D。

    • 来自D:D当前PageRank为1/10,指向3个节点。E获得 (1/3) * (1/10) = 1/30 ≈ 0.033

经过两步迭代,节点的PageRank排序为:B (0.433) > C (0.233) > D (0.2) > A (0.1) > E (0.033)。这表明节点B目前是网络中最重要的节点。

收敛性与最终结果

你可能会问,应该迭代多少次?何时停止?如果继续迭代K=3,4,5...步会怎样?

对于这个特定网络,如果持续迭代很多很多步,PageRank值的变化会越来越小,最终收敛到一个唯一的值。这个收敛值才是我们最终认为的节点的PageRank。

上图展示了该网络PageRank的最终收敛值:

  • B: 0.38
  • C: 0.25
  • D: 0.19
  • A: 0.12
  • E: 0.06

节点的重要性排序保持不变,但数值稳定在了这些收敛值上。对于大多数网络,当迭代次数K趋近于无穷大时,PageRank值都会收敛到一个唯一解。

总结 🎯

本节课我们一起学习了基础PageRank算法。我们来总结一下关键步骤:

  1. 初始化:为网络中所有N个节点分配初始PageRank值 1/N
  2. 迭代更新:重复执行以下更新规则K次:
    • 每个节点将其当前PageRank值平均分配给所有它指向的节点。
    • 每个节点新的PageRank值,是所有指向它的节点所给予的PageRank值之和。
    • 用公式可表示为:PR_new(node) = sum( PR_old(source) / OutDegree(source) ),其中求和针对所有指向node的源节点source
  3. 收敛:对于大多数网络,随着迭代次数K增加,PageRank值会收敛到一组稳定的唯一值。这些收敛值即代表了节点在网络中的最终重要性评分。

PageRank通过模拟一个“随机冲浪者”在链接间跳转的过程,巧妙地解决了节点重要性评估中看似循环定义的问题,是网络分析中一个强大而基础的工具。

34:扩展PageRank算法 📈

在本节课中,我们将学习如何解释PageRank算法,识别其潜在问题,并了解一个名为“阻尼参数”的解决方案。我们将从随机游走的角度理解PageRank,并学习如何通过调整参数来优化算法在网络分析中的应用。


PageRank的随机游走解释 🚶‍♂️

上一节我们介绍了PageRank及其在网络上的计算方法。本节中,我们来看看如何从随机游走的角度解释PageRank值。

经过K步后,一个节点的PageRank值可以解释为一个随机游走者在进行K次随机移动后,落在该节点上的概率。

以下是随机游走过程的描述:

  1. 从网络中的一个随机节点开始。
  2. 随机选择一条从当前节点出发的出边,并沿着这条边移动到下一个节点。
  3. 重复步骤2,共进行K次。

例如,在下图所示的网络中,进行一次5步的随机游走:

  • 第一步:从随机节点D开始,随机选择一条边(例如D→A),移动到A。
  • 第二步:从A出发,唯一的选择是A→B,移动到B。
  • 第三步:从B出发,随机选择一条边(例如B→C),移动到C。
  • 第四步:从C出发,唯一的选择是C→B,移动回B。
  • 第五步:再次从B出发,随机选择一条边(例如B→D),移动到D。

这就是一次随机游走。基于此,我们可以将每个节点的PageRank值理解为长时间随机游走后停留在该节点的概率。


基础PageRank的问题 🚧

我们之前计算了示例网络的PageRank值。当K趋近于无穷大时,这些值会收敛。例如,节点B的PageRank值最高,为0.38,这意味着在进行了大量随机游走后,有38%的概率会落在节点B上。

现在,让我们对网络做一个小改动:添加两个新节点F和G,其中B指向F和G,而F和G互相指向。

请思考:当K非常大时,每个节点的PageRank值会是多少?

通过随机游走的解释,你应该能推断出:对于足够大的K,节点F和G的PageRank值将各约为0.5,而其他所有节点的PageRank值都将为0。

原因如下:一旦随机游走进入F或G节点,由于它们只互相连接,没有指向网络中其他部分的边,游走者将被困在这个“陷阱”中,无法离开。因此,在极长的游走后,落在其他节点上的概率为0,而落在F或G上的概率则各占一半。

这显然是一个问题。虽然F和G可能因此显得“重要”,但认为网络中其他所有节点的重要性都为0是不合理的。我们需要一种方法来修正这个问题。


解决方案:带阻尼参数的PageRank 🛡️

为了解决上述问题,我们在PageRank计算中引入了一个新参数,称为阻尼参数,通常用 α 表示。

我们对随机游走的规则进行了修改,引入了“随机跳转”的机制。新的游走过程如下:

  1. 从一个随机节点开始。
  2. 在每一步,有两种选择:
    • 以概率 α,按照原有规则,随机选择一条出边并跟随它。
    • 以概率 1 - α,忽略所有边,完全随机地跳到网络中的任意一个节点(包括当前节点)。
  3. 重复此过程K次。

这种新的游走方式被称为带阻尼参数的随机游走。一个节点经过K步后的缩放PageRank值,就是这种新游走方式下落在该节点上的概率。

引入阻尼参数后,即使游走者进入了像F和G这样的“陷阱”,由于有 1 - α 的概率会进行完全随机的跳转,它最终也能跳出陷阱,访问网络中的其他部分。


参数选择与实际应用 ⚙️

与基础PageRank类似,对于大多数网络,当K足够大时,缩放PageRank值会收敛到一个唯一值,但这个值取决于你选择的 α 的具体数值。

在实践中,我们通常将 α 设置在 0.8 到 0.9 之间。这意味着大部分时间(80%-90%)游走者会遵循链接,但仍有小部分时间(10%-20%)会进行随机跳转,从而避免被困在任何局部区域。

让我们看看在 α = 0.8 且K很大时,修改后网络中各个节点的缩放PageRank值:

  • F和G仍然拥有相对较高的PageRank值。
  • 但关键的是,其他节点(A到E)的PageRank值不再为0。
  • 并且,这些节点的重要性排序与基础PageRank计算时一致:B最高,其次是C,D和A大致相当,最后是E。

因此,带阻尼参数的PageRank既保留了F和G的重要性,又没有完全剥夺其他节点的权重。这种方法在像万维网或大型社交网络这样的大规模网络中效果更好。在我们的小例子中,它很好地展示了其工作原理。

在NetworkX库中,你可以使用 pagerank 函数来计算带阻尼参数的PageRank。函数调用方式如下:

# 假设 G 是你的网络图
alpha = 0.85  # 设置阻尼参数
scaled_pagerank_scores = nx.pagerank(G, alpha=alpha)

总结 📝

本节课中我们一起学习了:

  1. PageRank的随机游走解释:基础PageRank值可以理解为长时间随机游走后停留在某个节点的概率。
  2. 基础PageRank的局限:在某些网络结构(如存在“陷阱”节点)中,基础PageRank会导致少数节点吸收几乎所有重要性,而其他节点的重要性被错误地评估为0。
  3. 带阻尼参数的PageRank:通过引入阻尼参数 α,我们修改了随机游走规则,使其在每一步有 1-α 的概率随机跳转到网络中的任意节点。这有效解决了游走者被困的问题。
  4. 实践应用:通常将 α 设为0.8到0.9之间,并在NetworkX中使用 nx.pagerank(G, alpha=alpha) 函数进行计算。

通过引入阻尼参数,我们获得了一个更健壮、更适合分析真实世界大型网络的PageRank算法版本。

35:枢纽与权威节点分析 🧭

在本节课中,我们将学习另一种在网络中寻找中心节点的方法——HITS算法。与PageRank类似,HITS算法也诞生于搜索引擎的背景下,旨在利用网页的超链接结构,从一组相关网页中找出最重要的页面。我们将了解算法的核心思想、计算步骤,并通过一个具体例子演示其工作原理。

概述

上一节我们介绍了PageRank算法,本节中我们来看看另一种基于链接分析的重要算法:HITS。HITS算法通过计算每个节点的两种分数——权威分数枢纽分数,来评估网页的重要性。权威页面是内容本身重要的页面,而枢纽页面则是善于链接到重要权威页面的页面。

算法步骤详解

HITS算法的执行分为几个关键步骤。

第一步:构建基础集合

首先,算法需要确定一个待分析的网络子集。

  1. 确定根集:根据用户查询,找到一组可能相关的网页(例如包含查询关键词的页面)。这些页面被称为“根集”,是潜在的权威页面。
  2. 扩展为基础集:找到所有链接到根集中任一页面的网页。这些页面是潜在的枢纽页面。根集与这些新发现的页面共同构成“基础集”。
  3. 构建分析网络:考虑基础集内所有节点之间的超链接,忽略指向集外或从集外指向的链接。这样就得到了一个用于HITS算法分析的子网络。

与PageRank分析整个网络不同,HITS从一个相关的子网络开始分析。

第二步:初始化与迭代更新

接下来,我们在构建好的网络上运行HITS算法。算法通过多次迭代更新每个节点的分数。

  1. 初始化:为网络中的每个节点赋予初始的权威分数和枢纽分数,通常都设为1。
    auth_score[node] = 1
    hub_score[node] = 1
    
  2. 迭代更新:在每次迭代中,按照以下两个规则更新所有节点的分数:
    • 权威分数更新规则:一个节点的权威分数等于所有指向它的节点的枢纽分数之和。
      new_auth[node] = sum(hub_score[neighbor] for neighbor in in_neighbors(node))
      
    • 枢纽分数更新规则:一个节点的枢纽分数等于它指向的所有节点的权威分数之和。
      new_hub[node] = sum(auth_score[neighbor] for neighbor in out_neighbors(node))
      
  3. 归一化:每次更新后,对所有的权威分数和枢纽分数分别进行归一化处理,防止其无限增长。例如,权威分数的归一化方法是将每个节点的分数除以所有节点权威分数的总和。
    total_auth = sum(new_auth.values())
    normalized_auth = {node: score/total_auth for node, score in new_auth.items()}
    
  4. 重复:将归一化后的分数作为下一轮的“旧分数”,重复步骤2和3,进行K次迭代。

实例演算

为了更直观地理解,让我们在一个简单的网络上手动计算两轮HITS迭代。

假设我们有一个小型网络,其根集包含节点A、B、C。经过扩展后,基础集包含节点A、B、C、D、E、F、G、H,它们之间的链接关系如图所示。

以下是第一轮迭代的计算过程:

  • 更新权威分数:由于所有节点的初始枢纽分数均为1,此时节点的权威分数就等于其入度(有多少节点指向它)。
  • 更新枢纽分数:由于所有节点的初始权威分数均为1,此时节点的枢纽分数就等于其出度(它指向多少节点)。
  • 归一化:将所有权威分数和枢纽分数分别求和(本例中两者总和均为15),然后每个分数除以总和。

第一轮迭代后,我们得到了归一化的权威分数和枢纽分数,它们将作为第二轮迭代的“旧分数”。

现在进入第二轮迭代,计算会变得更有趣,因为节点的分数不再相同。

以下是第二轮迭代中节点A的分数计算示例:

  • 计算A的新权威分数:找出指向A的节点(C, G, H),将它们的旧枢纽分数相加。
  • 计算A的新枢纽分数:找出A指向的节点(D),取该节点的旧权威分数。

对所有节点完成上述计算后,再次分别对权威分数和枢纽分数进行归一化,即得到第二轮迭代后的最终分数。

收敛性与结果解读

就像PageRank一样,我们关心当迭代次数K不断增大时,分数是否会收敛到一个稳定值。

对于大多数网络,HITS算法的权威分数和枢纽分数确实会收敛到唯一的值。在我们的例子中,经过足够多次迭代后:

  • 具有最高权威分数的节点是B和C。这符合预期,因为B和C本身就是根集中的“潜在权威”页面。
  • 具有最高枢纽分数的节点是D和E。观察网络结构可以发现,D和E都链接到了B和C这两个重要的权威页面,因此它们被认为是优秀的“枢纽”。

使用NetworkX实现

在实际应用中,我们可以使用Python的networkx库轻松计算HITS分数。

import networkx as nx

# G 是构建好的网络图
hubs, authorities = nx.hits(G)
# hubs 是一个字典,键为节点,值为枢纽分数
# authorities 是一个字典,键为节点,值为权威分数

nx.hits()函数接收一个图作为输入,并返回两个分别包含所有节点枢纽分数和权威分数的字典。

总结

本节课我们一起学习了HITS算法。我们了解到,HITS算法首先构建一个相关网页的根集,并扩展为基础集。然后,它为网络中每个节点分配一个权威分数和一个枢纽分数:拥有来自优秀枢纽的入链的节点被认为是好的权威拥有指向优秀权威的出链的节点被认为是好的枢纽。对于大多数网络,这两种分数都会收敛。我们可以使用networkx库中的hits()函数来计算任何网络的这些分数。

36:中心性度量应用实例与比较

在本节课中,我们将通过一个具体的网络实例,比较之前学过的几种中心性度量方法如何对节点进行不同的重要性排序。我们将看到,不同的度量标准基于不同的假设,因此会产生不同的排名结果。

上一节我们介绍了多种识别网络中关键节点的方法,本节中我们来看看如何在一个具体网络中应用并比较这些方法。

网络结构与初始分析

我们将分析上图所示的特定网络。首先,从最基础的网络中心性概念——入度中心性开始。

入度中心性衡量的是有多少节点指向你。在这个网络中,应用此度量标准会发现,节点1和节点6拥有最高的入度值。

以下是基于入度中心性的节点排名:

  • 节点1和节点6:入度为4,排名最高。
  • 其他所有节点:入度为2,并列第二。

入度中心性只能区分出节点1和6最为重要,而其他节点的重要性相同。接下来,我们将逐一查看其他度量方法。与入度中心性类似,我会将节点按重要性从高到低排列,并用红色横线表示排名出现分界的位置。

接近中心性分析

现在让我们看看接近中心性。请记住,接近中心性认为,中心节点应该与网络中所有其他节点的距离都很短。

使用此度量,我们发现节点5是最中心的节点。这很合理,因为节点5位于网络中心,大多数节点只需少量跳数即可到达节点5。对于其他节点(如节点3或4),到达它们需要更多跳数。例如,从节点8到节点3需要多跳,因此节点5具有较高的接近中心性是合理的。

以下是基于接近中心性的节点排名:

  • 节点5:排名最高。
  • 节点1和6:其次。
  • 节点2和7:比节点4、3、8、9更接近中心,但不如5、1、6重要。
  • 节点4、3、8、9:接近中心性最低,且此度量无法区分它们之间的差异。

节点3和4具有相同的接近中心性,因为要到达其中任一节点,都必须先经过节点2,然后只需一跳即可到达节点3或4。节点8和9的情况类似。然而,节点3和4存在结构差异:从节点3可以直接到达节点2,但从节点4到节点2必须经过节点3。接近中心性无法捕捉这种差异。

中介中心性分析

接下来我们分析中介中心性。中介中心性认为,中心节点是那些出现在网络中不同节点对之间最短路径上的节点。

使用此度量,节点5具有最高的中介中心性。这很合理,因为它在网络中位置居中,出现在许多节点对的最短路径上。

以下是基于中介中心性的节点排名:

  • 节点5:排名最高。
  • 节点1和6:其次。
  • 节点2和7:再次。
  • 节点3和8:随后。
  • 节点4和9:中介中心性最低。

与接近中心性不同,中介中心性能够捕捉节点3和4之间的结构差异。如前所述,从节点2到节点4必须经过节点3,而节点3可以直接到达节点2,因此节点3的中介中心性高于节点4。中介中心性的结果与接近中心性相似,但它能捕捉到接近中心性忽略的结构差异。

PageRank分析

现在让我们看看PageRank。PageRank有一个有用的解释:如果你在这个网络上进行随机游走,中心节点就是你经常经过或最终停留的节点。

在这个网络中,PageRank的结果与中介中心性有所不同。

以下是基于PageRank的节点排名:

  • 节点1和6:排名最高。
  • 节点5:其次。
  • 节点2、7、3、8、4、9:随后的排名。

为什么会出现这种情况?请注意,节点5将其所有“声望”都传递给了节点1和6,而节点1和6除了传递部分声望给节点5,还传递给其他两个节点。这是节点5排名次于节点1和6的部分原因。PageRank的结果与中介中心性相似,但颠倒了节点1、6与节点5的次序。

HITS算法权威值分析

最后,我们看看HITS算法计算的权威值。该算法为每个节点计算权威值和枢纽值。

权威值的结果起初可能令人惊讶。

以下是基于HITS权威值的节点排名:

  • 节点1和6:排名最高。
  • 节点4和9:其次。
  • 节点3和8:随后。
  • 节点2和7:再次。
  • 节点5:权威值最低。

节点5的权威值最低,尽管在其他许多中心性度量中它都非常重要。为什么会这样?要理解HITS算法的结果,必须同时查看权威值和枢纽值。在这个网络中,节点2、5和7拥有很高的枢纽值。HITS算法将此网络解释为:节点1和6是权威节点,而节点2、5和7是具有高枢纽值的节点。因此,要合理解释这些分数,必须将它们放在一起看。

总结与比较

本节课中我们一起学习了如何在一个网络实例中应用并比较不同的中心性度量方法。

我们在这里看到,所有这些度量方法给出了不同的节点排名,尽管存在一些共同点。例如,它们通常都认为节点1、5和6具有较高的中心性,但也存在一些差异。

总结来说,在这个例子中,没有一对中心性度量产生了完全相同的排名,但存在一些共性,使我们能够识别出一些非常中心的节点。当然,这些中心性度量对“什么是中心节点”做出了不同的假设,因此它们产生了不同的排名。

要确定哪种中心性度量最好,实际上取决于你所分析网络的具体背景。通常,识别中心节点的最佳做法是采用多种中心性度量,找出在多种度量中都表现重要的节点,而不是仅仅依赖某一种方法。

希望这个例子能帮助你理解这些不同中心性度量方法的比较方式,并看清它们之间的差异。本节内容到此结束,我们下次再见。

37:优先连接模型

在本节课中,我们将学习网络中的度分布概念,探讨真实网络中常见的幂律分布现象,并介绍一种能够生成此类网络的模型——优先连接模型。

度分布

在上一节中,我们了解了节点的度。有时,我们不仅关心单个节点的度,更关心网络中所有节点的度是如何分布的。

节点的度分布是指网络中所有节点的度的概率分布。对于一个无向网络,如果我们用 P(k) 表示度为 k 的概率,那么可以通过统计每个度值出现的节点数量来计算。

以下是计算和绘制度分布的步骤:

  1. 使用 degrees() 函数获取一个字典,其中键是节点,值是节点的度。
  2. 将所有节点的度值提取出来,排序后形成一个列表。
  3. 统计每个度值出现的频率,生成直方图。
  4. 使用条形图绘制这个直方图。
# 示例:计算并绘制无向图的度分布
degrees = G.degree()
degree_values = sorted([d for n, d in degrees])
hist = {}
for d in degree_values:
    hist[d] = hist.get(d, 0) + 1
# 绘制条形图...

对于有向图,我们通常分别关注入度分布和出度分布。计算入度分布的方法与上述类似,但使用 in_degree() 函数。

真实网络的度分布

观察真实网络的度分布时,有两个关键现象:

  1. 坐标轴通常采用对数刻度。
  2. 在对数-对数坐标下,度分布常常近似为一条直线。

这种在对数-对数坐标下呈直线的分布,被称为幂律分布。其数学形式为:
P(k) = C * k^(-α)
其中,Cα 是常数。

幂律分布的特点是:网络中绝大多数节点的度非常小,但同时存在少数连接数极高的“枢纽”节点。

优先连接模型

为了解释为何众多真实网络都呈现幂律度分布,科学家提出了网络生成模型。优先连接模型就是其中之一,它能生成具有幂律度分布的网络。

该模型的工作原理如下:

  1. 初始时,网络有两个通过一条边连接的节点。
  2. 在每个时间步,添加一个新节点。
  3. 这个新节点会连接到网络中的一个现有节点。
  4. 关键机制:新节点选择连接哪个现有节点不是随机的,而是概率正比于该现有节点当前的度。即,一个节点已有的连接越多,新节点连接它的概率就越大。

连接到一个现有节点 u 的概率公式为:
P(连接到u) = ku / Σ kv
其中,ku 是节点 u 的度,分母是所有节点度的总和。

这个过程导致了“富者愈富”的现象:度高的节点更容易获得新连接,从而度变得更高。随着网络规模增大,该模型生成的网络的度分布会趋近于幂律分布。

在 NetworkX 中实现

在 NetworkX 库中,可以使用 barabasi_albert_graph(n, m) 函数来生成遵循优先连接模型的网络。

以下是该函数的参数说明:

  • n:最终网络中的节点总数。
  • m:每个新加入的节点会连接到现有节点的数量(在基础模型中通常设为1)。
import networkx as nx
# 生成一个具有100万个节点,每个新节点连接1个现有节点的网络
G = nx.barabasi_albert_graph(n=1000000, m=1)
# 绘制其度分布(使用散点图和对数坐标)
# ... 绘图代码

在对数-对数坐标下绘制该网络的度分布,可以观察到近似直线的特征,验证了其幂律属性。

总结

本节课我们一起学习了以下内容:

  1. 度分布:网络中节点度的概率分布,是描述网络整体连接模式的重要指标。
  2. 幂律现象:许多真实网络(如社交网络、万维网)的度分布在双对数坐标下呈直线,即服从幂律分布,其特点是存在少数高度连接的枢纽节点。
  3. 优先连接模型:一种网络生成模型,通过“新节点以正比于现有节点度的概率进行连接”这一简单规则,成功地解释了幂律度分布的产生机制,体现了“富者愈富”的动力学过程。
  4. 工具应用:我们了解了如何使用 NetworkX 中的 barabasi_albert_graph 函数来生成具有幂律特性的模拟网络。

网络生成模型帮助我们理解观察到的网络模式背后可能存在的形成机制。优先连接模型为我们理解真实网络中枢纽节点的出现提供了一个清晰而有力的理论框架。

38:小世界网络

在本节课中,我们将要学习“小世界现象”,并探讨如何通过数学模型来生成具有“短平均路径”和“高聚类系数”这两个关键特性的网络。

概述

“小世界现象”指出,尽管世界人口众多,但人与人之间通常通过很短的路径相连。本节课我们将首先回顾验证这一现象的经典实验,然后分析真实社交网络的数据,最后介绍一个能同时生成短平均路径和高聚类系数的网络模型——小世界模型。

小世界实验

上一节我们提到了小世界现象,本节中我们来看看如何通过实验来验证它。

在20世纪60年代,研究员斯坦利·米尔格拉姆设计了一个实验来测量人际网络中的路径长度。实验选取了296名遍布美国的随机参与者作为“发件人”,要求他们将一封信转发给波士顿的一位股票经纪人“目标人物”。规则是:如果发件人认识目标,则直接寄出;如果不认识,则需将信寄给自己认识的、且更可能认识目标的人。信件中包含目标所在城市和职业等信息。

实验结果如下:在296封信中,有64封最终送达了目标。成功送达的信件所需的中转次数(路径长度)中位数为6。这与“六度分隔”的说法相符。下图展示了成功送达信件的路径长度分布直方图,可见路径长度在1到10次之间,中位数为6。

以下是该实验的关键发现:

  • 首先,考虑到参与者是随机选取的且可能中途放弃,超过20%的信件最终送达目标,这个比例相对较高。
  • 其次,对于成功送达的信件,其路径长度相对较短。在拥有数百万人的网络中,中位数仅为6,这显得非常“小”。
  • 另一个有趣的现象是,人们似乎有能力找到这些短路径,而不仅仅是这些路径存在。

现代数据验证

近年来,研究者尝试利用大规模网络数据来验证小世界现象,而无需进行实体实验。

一项研究分析了微软即时通讯网络中2.4亿活跃用户的数据。如果两个用户在一个月内进行过双向通信,则在他们之间建立连接,从而构成一个巨大的网络。该网络中,任意两个用户之间最短路径长度的中位数估计为7,这与米尔格拉姆实验得出的6非常接近,并且同样很小。下图是该网络的距离分布直方图。

另一项基于Facebook数据的研究发现,全球网络的平均路径长度在2008年约为5.28,到2011年则降至4.74。这表明路径长度不仅短,而且随着时间推移似乎在变得更短。如果仅观察美国这样的区域性子网络,平均路径长度则更小。

综上所述,在拥有数百万节点的真实社交网络中,节点对之间的平均最短路径长度往往非常小。这是我们要讨论的第一个特性。

聚类系数特性

接下来,我们讨论第二个特性:聚类系数。我们之前介绍过,节点的局部聚类系数衡量的是其邻居之间彼此也相连的比例,粗略反映了网络中三角形的多少。所有节点局部聚类系数的平均值即为网络的平均聚类系数。

在真实网络中,例如2011年的Facebook网络,平均聚类系数往往相当高。下图展示了节点度数与平均聚类系数的关系:随着节点度数增加,其平均聚类系数会下降,但对于拥有20到50个朋友的用户,其聚类系数仍在0.3左右。

在微软即时通讯网络中,平均聚类系数为0.13。在我们之前讨论过的演员合作网络中,平均聚类系数甚至高达0.78。

这些聚类系数之所以被认为是“高”的,是因为如果网络是完全随机的(即随机连接节点),其平均聚类系数会低得多。高聚类系数表明,网络的形成过程中可能存在某种机制,促进了三角形(即紧密的社交圈)的产生。

现有模型的局限性

我们已经注意到社交网络的两个特性:高聚类系数和短平均路径长度。那么,是否存在一个模型能生成同时具备这两种特性的网络呢?就像我们之前为幂律度分布寻找生成模型一样。

首先,很自然地会想到检查已有的网络形成模型——偏好依附模型。让我们创建一个包含1000个节点、参数 m=4(每个新节点连接4个现有节点)的网络,并查看其平均聚类系数和平均最短路径长度。

# 示例:检查偏好依附模型的特性
# 注意:此为概念性代码,实际需使用networkx等库实现
average_clustering = 0.02  # 假设计算得到
average_shortest_path = 4.16  # 假设计算得到

在这个例子中,平均聚类系数仅为0.02,远低于我们之前观察到的真实网络值。而平均最短路径长度为4.16,确实很短。这表明偏好依附模型能产生短平均路径,但无法产生高聚类系数。

如果我们改变节点数 N 和参数 m 来观察路径长度和聚类系数的变化,会发现:随着网络规模增大,平均最短路径保持较小,但平均聚类系数会持续下降并变得非常小(例如在2000个节点时约为0.05)。原因在于,偏好依附模型中没有促进三角形形成的机制,因此无法产生高聚类系数特性。

小世界模型

既然偏好依附模型不能满足要求,我们现在来介绍一个新的模型——小世界模型,它能够生成同时具备短平均路径和高聚类系数的网络。

模型机制

首先,让我们讨论这个模型是如何工作的。

  1. 初始化环形格点:首先,将 N 个节点排列成一个环。每个节点与其左右各 K/2 个最近邻节点相连(共 K 条边)。K 是一个偶数参数。
  2. 随机重连:定义一个重连概率参数 p(取值范围0到1)。对于第一步中创建的每一条边,以概率 p 进行“重连”。具体操作是:对于边 (u, v),以概率 p 随机选择网络中的另一个节点 ww ≠ u),并将边重连为 (u, w)。以概率 1-p 则保持原边不变。

我们通过一个例子来演示。假设有12个节点,参数 K=2(每个节点连接左右各1个邻居),p=0.4。我们遍历每条边,根据概率决定是否重连。经过一系列随机决策和重连操作后,最终得到一个小世界网络实例。

参数 p 的影响

参数 p 是模型中更有趣的参数,让我们思考它如何影响网络特性。

考虑两种极端情况:

  • p = 0:没有边被重连。网络是一个规则的环形格点。由于局部连接紧密,会形成很多三角形,因此聚类系数非常高。但是,要从环的一侧到达另一侧,只能一步步“跳”过去,缺乏“长桥”连接,因此平均路径长度会很长
  • p = 1:所有边都被重连。网络变得近似完全随机。随机连接创造了许多“长桥”,因此平均路径长度变得很短。但同时,最初的局部结构被完全破坏,三角形大量减少,因此聚类系数变得非常低

p 介于0和1之间时:部分边被重连,创建了一些“长桥”,这能显著降低平均路径长度。同时,根据 p 的大小,相当一部分局部结构得以保留,从而可以维持较高的聚类系数。因此,存在一个 p 的“甜蜜点”,可以同时获得短平均路径和高聚类系数。

特性分析

小世界网络的平均聚类系数和平均最短路径长度取决于参数 NKp

下图展示了随着 p 从0增加到0.1(注意这里 p 值很小),平均最短路径长度迅速下降,而平均聚类系数虽然也下降,但下降速度要慢得多。

例如,一个 N=1000K=6p=0.04 的小世界网络实例,其平均最短路径长度为8.99,平均聚类系数为0.53。对于这类 p 值,我们确实可以同时获得短的平均路径(个位数)和较大的平均聚类系数。

在 NetworkX 库中,可以使用 watts_strogatz_graph 函数来生成小世界网络,该函数以研究者命名。

import networkx as nx
# 生成一个小世界网络
N = 1000  # 节点数
K = 6     # 每个节点的初始近邻连接数
p = 0.04  # 重连概率
G = nx.watts_strogatz_graph(N, K, p)

度分布

小世界网络的度分布是怎样的?让我们用之前可视化度分布的代码,来观察一个 N=1000K=6p=0.04 的网络。

我们发现,其度分布大致如下:大多数节点的度数为6,少数为5或7,极少数为4或8。这是合理的,因为重连概率 p 很小,大多数边未被重连,节点保持了初始的度数 K。由于模型中没有让某些节点积累大量连接的机制,所以不会出现幂律分布。

因此,小世界模型虽然成功捕获了高聚类系数和短平均路径这两个特性,但它无法产生真实网络中常见的幂律度分布,而这是偏好依附模型所具备的。

模型变体

NetworkX 中还提供了一些小世界模型的变体:

  • connected_watts_strogatz_graph:标准小世界模型可能产生不连通的网络。此函数通过多次尝试(最多 t 次),确保生成一个连通的小世界网络。
  • newman_watts_strogatz_graph:与标准模型类似,但不同之处在于,它以概率 p 添加新边,而不是重连旧边。即保留原始边,并额外添加随机边。

总结

本节课中,我们一起学习了小世界网络的相关知识。

  1. 我们首先回顾了米尔格拉姆的经典实验和现代大数据分析,发现真实社交网络普遍具有短的平均最短路径高的平均聚类系数
  2. 接着,我们检验了之前学过的偏好依附模型,发现它虽然能产生短平均路径,但无法产生高聚类系数。
  3. 为此,我们引入了小世界模型。该模型始于一个每个节点连接 K 个最近邻的环形格点(具有高聚类特性),然后以概率 p 对边进行随机重连。
  4. 我们发现,当 p 取值很小时(如0.04),生成的小世界网络既能通过新增的“长桥”获得短平均路径,又能保持较高的聚类系数,这与真实网络的观察结果相符。
  5. 然而,小世界网络的度分布并非幂律分布,这与真实网络的另一个常见特性不符。
  6. 最后,我们介绍了在 NetworkX 中使用 watts_strogatz_graph 函数及其变体来生成小世界网络的方法。

小世界模型为我们理解社交网络的结构提供了一种简洁而有力的工具。

39:链接预测技术 🔗

在本节课中,我们将学习链接预测技术。链接预测的核心问题是:给定一个静态网络,我们能否预测未来哪些节点之间会形成新的连接?这个问题在社交网络好友推荐、学术合作预测等领域有广泛应用。

上一节我们讨论了网络作为动态结构的演化模型。本节中,我们将具体探讨如何量化评估网络中任意两个节点未来形成连接的可能性。

问题定义与示例

让我们以一个简单网络为例。我们的目标是:评估网络中尚未连接的任意一对节点,在未来形成连接的可能性。

以下是链接预测中常用的七种衡量指标。我们将逐一介绍其定义、直观解释以及在NetworkX库中的实现方法。

1. 共同邻居数

这是最简单直接的衡量指标。其核心思想源于三元闭包理论:在社交网络中,拥有共同朋友的人更有可能成为朋友。

公式:对于节点 xy,其共同邻居数定义为它们邻居集合的交集大小。
CN(x, y) = |N(x) ∩ N(y)|
其中,N(x) 表示节点 x 的邻居集合。

示例:在示例网络中,节点 A 和 C 的共同邻居是 B 和 D,因此 CN(A, C) = 2

代码实现

# 使用 NetworkX 的 common_neighbors 函数
common_neighbors = list(nx.common_neighbors(G, 'A', 'C'))
# 计算数量
cn_score = len(common_neighbors)

2. Jaccard 系数

共同邻居数的一个缺点是它没有考虑节点自身的“流行度”。Jaccard 系数通过将共同邻居数归一化到两个节点总邻居数来解决这个问题。

公式
Jaccard(x, y) = |N(x) ∩ N(y)| / |N(x) ∪ N(y)|

示例:节点 A 和 C 有 2 个共同邻居(B, D),总共有 4 个不同的邻居(B, D, E, F)。因此,Jaccard(A, C) = 2/4 = 0.5

代码实现

# 使用 NetworkX 的 jaccard_coefficient 函数
# 该函数返回一个迭代器,包含所有非边节点对及其 Jaccard 系数
jaccard_preds = nx.jaccard_coefficient(G)

3. 资源分配指数

该指标基于一个直观想法:如果节点 x 想通过共同邻居 z 向节点 y 传递资源(如信息),那么 z 会将该资源平均分配给它的所有邻居。因此,y 能收到的资源量与 z 的度数成反比。

公式
RA(x, y) = Σ_{z ∈ N(x) ∩ N(y)} 1 / degree(z)

示例:节点 A 和 C 的共同邻居是 B 和 D,它们的度数都是 3。因此,RA(A, C) = 1/3 + 1/3 ≈ 0.667

代码实现

# 使用 NetworkX 的 resource_allocation_index 函数
ra_preds = nx.resource_allocation_index(G)

4. Adamic-Adar 指数

该指数与资源分配指数非常相似,区别在于分母取的是度数的对数。这降低了对高度数邻居的惩罚力度。

公式
AA(x, y) = Σ_{z ∈ N(x) ∩ N(y)} 1 / log(degree(z))

示例:节点 A 和 C 的共同邻居 B 和 D 的度数都是 3。log(3) ≈ 1.099。因此,AA(A, C) = 1/1.099 + 1/1.099 ≈ 1.82

代码实现

# 使用 NetworkX 的 adamic_adar_index 函数
aa_preds = nx.adamic_adar_index(G)

5. 优先连接分数

该指标基于优先连接模型的原理:度数高的节点更容易获得新连接。因此,两个高度数节点未来连接的可能性更高。

公式
PA(x, y) = degree(x) * degree(y)

示例:节点 A 的度数为 3,节点 C 的度数为 3。因此,PA(A, C) = 3 * 3 = 9

代码实现

# 使用 NetworkX 的 preferential_attachment 函数
pa_preds = nx.preferential_attachment(G)

6. 带社区加权的共同邻居数(Soundarajan-Hopcroft)

前面的指标都未考虑网络中的社区结构。如果我们已知节点的社区归属(例如,公司中的不同部门),则可以引入社区信息。该指标为属于同一社区的共同邻居提供额外加分。

公式
CN_SH(x, y) = |N(x) ∩ N(y)| + Σ_{z ∈ N(x) ∩ N(y)} f(z)
其中,如果共同邻居 z 与节点 xy 属于同一社区,则 f(z) = 1,否则为 0

示例:假设节点 A, B, C, D 属于社区 1,其余节点属于社区 2。节点 A 和 C 的共同邻居 B 和 D 都属于社区 1,因此 CN_SH(A, C) = 2 + 1 + 1 = 4

代码实现
首先需要为网络中的节点添加社区属性。

# 为节点添加社区属性
community_map = {'A': 0, 'B': 0, 'C': 0, 'D': 0, 'E': 1, 'F': 1, 'G': 1, 'H': 1, 'I': 1}
nx.set_node_attributes(G, community_map, 'community')

# 使用带社区信息的共同邻居函数
cn_sh_preds = nx.cn_soundarajan_hopcroft(G)

7. 带社区加权的资源分配指数(Soundarajan-Hopcroft)

这是资源分配指数的社区加权版本。它只考虑那些与目标节点对属于同一社区的共同邻居。

公式
RA_SH(x, y) = Σ_{z ∈ N(x) ∩ N(y)} f(z) / degree(z)
其中,f(z) 的定义同上:如果 zxy 同社区则为 1,否则为 0。

示例:节点 A 和 G 的共同邻居是 E。但 A 属于社区 1,G 属于社区 2,E 属于社区 1。因此 E 不与 A 和 G 同时同社区,f(E) = 0,所以 RA_SH(A, G) = 0

代码实现

# 使用带社区信息的资源分配指数函数
ra_sh_preds = nx.ra_index_soundarajan_hopcroft(G)

重要说明与应用建议

在介绍了七种指标后,有两点需要特别注意:

  1. 分数而非决策:这些指标计算出的都是一个“可能性分数”,而不是一个“是或否”的预测。分数越高,表示连接形成的可能性越大。
  2. 指标间的不一致性:不同的指标可能会对同一对节点给出不同的排序结果。例如,有些指标认为 (I, H) 比 (A, G) 更可能连接,而另一些指标则相反。

因此,在实际解决链接预测问题时,通常的做法是:

  • 将这些指标的计算结果作为特征
  • 如果拥有带标签的历史数据(即已知哪些节点对后来确实形成了连接),则可以使用这些特征来训练一个分类器(如逻辑回归、随机森林等)。
  • 最终使用训练好的分类器模型来对新节点对进行预测。

总结

本节课我们一起学习了链接预测问题及其七种核心衡量指标:

  1. 共同邻居数:最基础的指标,直接计算共享邻居的数量。
  2. Jaccard 系数:对共同邻居数进行归一化,考虑节点总邻居数。
  3. 资源分配指数:模拟资源通过共同邻居传递的效率,惩罚高度数共同邻居。
  4. Adamic-Adar 指数:资源分配指数的变体,使用度数的对数作为分母。
  5. 优先连接分数:基于“富者愈富”原理,高度数节点对的分数更高。
  6. 带社区加权的共同邻居数:在已知社区信息时,为同社区共同邻居加分。
  7. 带社区加权的资源分配指数:只考虑同社区共同邻居的资源传递效率。

理解这些指标的原理和适用场景,是构建有效链接预测系统的第一步。在实际应用中,通常需要结合多种指标,并利用机器学习方法进行综合预测。

posted @ 2026-03-26 13:08  布客飞龙IV  阅读(9)  评论(0)    收藏  举报