从零开始的数据科学第二版(六)
原文:
zh.annas-archive.org/md5/48ab308fc34189a6d7d26b91b72a6df9译者:飞龙
第二十二章:网络分析
你与你周围所有事物的连接实际上定义了你是谁。
Aaron O’Connell
许多有趣的数据问题可以通过网络的方式进行有益的思考,网络由某种类型的节点和连接它们的边组成。
例如,你的 Facebook 朋友形成一个网络的节点,其边是友谊关系。一个不那么明显的例子是互联网本身,其中每个网页是一个节点,每个从一个页面到另一个页面的超链接是一条边。
Facebook 友谊是相互的——如果我在 Facebook 上是你的朋友,那么必然你也是我的朋友。在这种情况下,我们称这些边是无向的。而超链接则不是——我的网站链接到whitehouse.gov,但(出于我无法理解的原因)whitehouse.gov拒绝链接到我的网站。我们称这些类型的边为有向的。我们将研究这两种类型的网络。
中介中心性
在第一章中,我们通过计算每个用户拥有的朋友数量来计算 DataSciencester 网络中的关键连接者。现在我们有足够的机制来看看其他方法。我们将使用相同的网络,但现在我们将使用NamedTuple来处理数据。
回想一下,网络(图 22-1)包括用户:
from typing import NamedTuple
class User(NamedTuple):
id: int
name: str
users = [User(0, "Hero"), User(1, "Dunn"), User(2, "Sue"), User(3, "Chi"),
User(4, "Thor"), User(5, "Clive"), User(6, "Hicks"),
User(7, "Devin"), User(8, "Kate"), User(9, "Klein")]
以及友谊:
friend_pairs = [(0, 1), (0, 2), (1, 2), (1, 3), (2, 3), (3, 4),
(4, 5), (5, 6), (5, 7), (6, 8), (7, 8), (8, 9)]
图 22-1. DataSciencester 网络
友谊将更容易作为一个dict来处理:
from typing import Dict, List
# type alias for keeping track of Friendships
Friendships = Dict[int, List[int]]
friendships: Friendships = {user.id: [] for user in users}
for i, j in friend_pairs:
friendships[i].append(j)
friendships[j].append(i)
assert friendships[4] == [3, 5]
assert friendships[8] == [6, 7, 9]
当我们离开时,我们对我们关于度中心性的概念并不满意,这与我们对网络中关键连接者的直觉并不完全一致。
另一种度量标准是中介中心性,它识别频繁出现在其他人对之间最短路径上的人。特别地,节点i的中介中心性通过为每对节点j和k添加上通过i的最短路径的比例来计算。
也就是说,要弄清楚 Thor 的中介中心性,我们需要计算所有不是 Thor 的人之间所有最短路径。然后我们需要计算有多少条这些最短路径通过 Thor。例如,Chi(id 3)和 Clive(id 5)之间唯一的最短路径通过 Thor,而 Hero(id 0)和 Chi(id 3)之间的两条最短路径都不通过 Thor。
因此,作为第一步,我们需要找出所有人之间的最短路径。有一些非常复杂的算法可以高效地完成这个任务,但是(几乎总是如此),我们将使用一种效率较低但更易于理解的算法。
这个算法(广度优先搜索的实现)是本书中比较复杂的算法之一,所以让我们仔细讨论一下它:
-
我们的目标是一个函数,它接受一个
from_user,并找到到每个其他用户的所有最短路径。 -
我们将路径表示为用户 ID 的
list。因为每条路径都从from_user开始,我们不会在列表中包含她的 ID。这意味着表示路径的列表长度将是路径本身的长度。 -
我们将维护一个名为
shortest_paths_to的字典,其中键是用户 ID,值是以指定 ID 结尾的路径列表。如果有唯一的最短路径,列表将只包含该路径。如果有多条最短路径,则列表将包含所有这些路径。 -
我们还会维护一个称为
frontier的队列,按照我们希望探索它们的顺序包含我们想要探索的用户。我们将它们存储为对(prev_user, user),这样我们就知道如何到达每一个用户。我们将队列初始化为from_user的所有邻居。(我们还没有讨论过队列,它们是优化了“添加到末尾”和“从前面删除”的数据结构。在 Python 中,它们被实现为collections.deque,实际上是一个双端队列。) -
在探索图形时,每当我们发现新的邻居,而我们还不知道到达它们的最短路径时,我们将它们添加到队列的末尾以供稍后探索,当前用户为
prev_user。 -
当我们从队列中取出一个用户,并且我们以前从未遇到过该用户时,我们肯定找到了一条或多条最短路径——每条最短路径到
prev_user再添加一步。 -
当我们从队列中取出一个用户,并且我们之前遇到过该用户时,那么要么我们找到了另一条最短路径(在这种情况下,我们应该添加它),要么我们找到了一条更长的路径(在这种情况下,我们不应该添加)。
-
当队列中没有更多的用户时,我们已经探索了整个图形(或者至少是从起始用户可达的部分),我们完成了。
我们可以把所有这些组合成一个(很大的)函数:
from collections import deque
Path = List[int]
def shortest_paths_from(from_user_id: int,
friendships: Friendships) -> Dict[int, List[Path]]:
# A dictionary from user_id to *all* shortest paths to that user.
shortest_paths_to: Dict[int, List[Path]] = {from_user_id: [[]]}
# A queue of (previous user, next user) that we need to check.
# Starts out with all pairs (from_user, friend_of_from_user).
frontier = deque((from_user_id, friend_id)
for friend_id in friendships[from_user_id])
# Keep going until we empty the queue.
while frontier:
# Remove the pair that's next in the queue.
prev_user_id, user_id = frontier.popleft()
# Because of the way we're adding to the queue,
# necessarily we already know some shortest paths to prev_user.
paths_to_prev_user = shortest_paths_to[prev_user_id]
new_paths_to_user = [path + [user_id] for path in paths_to_prev_user]
# It's possible we already know a shortest path to user_id.
old_paths_to_user = shortest_paths_to.get(user_id, [])
# What's the shortest path to here that we've seen so far?
if old_paths_to_user:
min_path_length = len(old_paths_to_user[0])
else:
min_path_length = float('inf')
# Only keep paths that aren't too long and are actually new.
new_paths_to_user = [path
for path in new_paths_to_user
if len(path) <= min_path_length
and path not in old_paths_to_user]
shortest_paths_to[user_id] = old_paths_to_user + new_paths_to_user
# Add never-seen neighbors to the frontier.
frontier.extend((user_id, friend_id)
for friend_id in friendships[user_id]
if friend_id not in shortest_paths_to)
return shortest_paths_to
现在让我们计算所有的最短路径:
# For each from_user, for each to_user, a list of shortest paths.
shortest_paths = {user.id: shortest_paths_from(user.id, friendships)
for user in users}
现在我们终于可以计算介数中心性了。对于每对节点i和j,我们知道从i到j的n条最短路径。然后,对于每条路径,我们只需将 1/n 添加到该路径上每个节点的中心性:
betweenness_centrality = {user.id: 0.0 for user in users}
for source in users:
for target_id, paths in shortest_paths[source.id].items():
if source.id < target_id: # don't double count
num_paths = len(paths) # how many shortest paths?
contrib = 1 / num_paths # contribution to centrality
for path in paths:
for between_id in path:
if between_id not in [source.id, target_id]:
betweenness_centrality[between_id] += contrib
如图 22-2 所示,用户 0 和 9 的中心性为 0(因为它们都不在任何其他用户之间的最短路径上),而 3、4 和 5 都具有很高的中心性(因为它们都位于许多最短路径上)。
图 22-2. DataSciencester 网络按介数中心性大小排序
注意
通常中心性数值本身并不那么有意义。我们关心的是每个节点的数值与其他节点的数值相比如何。
另一个我们可以看的度量标准是closeness centrality。首先,对于每个用户,我们计算她的farness,即她到每个其他用户的最短路径长度之和。由于我们已经计算了每对节点之间的最短路径,所以将它们的长度相加很容易。(如果有多条最短路径,它们的长度都相同,所以我们只需看第一条。)
def farness(user_id: int) -> float:
"""the sum of the lengths of the shortest paths to each other user"""
return sum(len(paths[0])
for paths in shortest_paths[user_id].values())
之后计算接近中心度(图 22-3)的工作量就很小:
closeness_centrality = {user.id: 1 / farness(user.id) for user in users}
图 22-3. 根据接近中心度调整大小的 DataSciencester 网络
这里变化很少——即使非常中心的节点距离外围节点也相当远。
正如我们所见,计算最短路径有点麻烦。因此,介数和接近中心度在大型网络上并不经常使用。不太直观(但通常更容易计算)的eigenvector centrality更常用。
特征向量中心度
要谈论特征向量中心度,我们必须谈论特征向量,而要谈论特征向量,我们必须谈论矩阵乘法。
矩阵乘法
如果A是一个n × m矩阵,B是一个m × k矩阵(注意A的第二维度与B的第一维度相同),它们的乘积AB是一个n × k矩阵,其(i,j)项为:
A i1 B 1j + A i2 B 2j + ⋯ + A im B mj
这只是A的第i行(看作向量)与B的第j列(也看作向量)的点积。
我们可以使用第四章中的make_matrix函数来实现这一点:
from scratch.linear_algebra import Matrix, make_matrix, shape
def matrix_times_matrix(m1: Matrix, m2: Matrix) -> Matrix:
nr1, nc1 = shape(m1)
nr2, nc2 = shape(m2)
assert nc1 == nr2, "must have (# of columns in m1) == (# of rows in m2)"
def entry_fn(i: int, j: int) -> float:
"""dot product of i-th row of m1 with j-th column of m2"""
return sum(m1[i][k] * m2[k][j] for k in range(nc1))
return make_matrix(nr1, nc2, entry_fn)
如果我们将一个m维向量视为(m, 1)矩阵,我们可以将其乘以一个(n, m)矩阵得到一个(n, 1)矩阵,然后我们可以将其视为一个n维向量。
这意味着另一种思考一个(n, m)矩阵的方式是将其视为将m维向量转换为n维向量的线性映射:
from scratch.linear_algebra import Vector, dot
def matrix_times_vector(m: Matrix, v: Vector) -> Vector:
nr, nc = shape(m)
n = len(v)
assert nc == n, "must have (# of cols in m) == (# of elements in v)"
return [dot(row, v) for row in m] # output has length nr
当A是一个方阵时,这个操作将n维向量映射到其他n维向量。对于某些矩阵A和向量v,当A作用于v时,可能得到v的一个标量倍数—也就是说,结果是一个指向v相同方向的向量。当这种情况发生时(并且此外v不是全零向量),我们称v是A的一个特征向量。我们称这个乘数为特征值。
找到A的一个特征向量的一个可能方法是选择一个起始向量v,应用matrix_times_vector,重新缩放结果使其大小为 1,并重复,直到过程收敛:
from typing import Tuple
import random
from scratch.linear_algebra import magnitude, distance
def find_eigenvector(m: Matrix,
tolerance: float = 0.00001) -> Tuple[Vector, float]:
guess = [random.random() for _ in m]
while True:
result = matrix_times_vector(m, guess) # transform guess
norm = magnitude(result) # compute norm
next_guess = [x / norm for x in result] # rescale
if distance(guess, next_guess) < tolerance:
# convergence so return (eigenvector, eigenvalue)
return next_guess, norm
guess = next_guess
通过构造,返回的 guess 是一个向量,使得当你将 matrix_times_vector 应用于它并将其重新缩放为长度为 1 时,你会得到一个非常接近其自身的向量——这意味着它是一个特征向量。
并非所有的实数矩阵都有特征向量和特征值。例如,矩阵:
rotate = [[ 0, 1],
[-1, 0]]
将向量顺时针旋转 90 度,这意味着它唯一将其映射到自身的标量倍数的向量是一个零向量。如果你尝试 find_eigenvector(rotate),它会无限运行。甚至具有特征向量的矩阵有时也会陷入循环中。考虑以下矩阵:
flip = [[0, 1],
[1, 0]]
此矩阵将任何向量 [x, y] 映射到 [y, x]。这意味着,例如,[1, 1] 是一个特征向量,其特征值为 1。然而,如果你从具有不同坐标的随机向量开始,find_eigenvector 将永远只是无限交换坐标。(像 NumPy 这样的非从头开始的库使用不同的方法,可以在这种情况下工作。)尽管如此,当 find_eigenvector 确实返回一个结果时,那个结果确实是一个特征向量。
中心性
这如何帮助我们理解 DataSciencester 网络?首先,我们需要将网络中的连接表示为一个 adjacency_matrix,其(i,j)th 元素为 1(如果用户 i 和用户 j 是朋友)或 0(如果不是):
def entry_fn(i: int, j: int):
return 1 if (i, j) in friend_pairs or (j, i) in friend_pairs else 0
n = len(users)
adjacency_matrix = make_matrix(n, n, entry_fn)
然后,每个用户的特征向量中心性就是在 find_eigenvector 返回的特征向量中对应于该用户的条目 (图 22-4)。
图 22-4. DataSciencester 网络的特征向量中心性大小
注意
基于远远超出本书范围的技术原因,任何非零的邻接矩阵必然具有一个特征向量,其所有值都是非负的。幸运的是,对于这个 adjacency_matrix,我们的 find_eigenvector 函数找到了它。
eigenvector_centralities, _ = find_eigenvector(adjacency_matrix)
具有高特征向量中心性的用户应该是那些拥有许多连接并且连接到自身中心性高的人的用户。
在这里,用户 1 和 2 是最中心的,因为他们都有三个连接到自身中心性高的人。随着我们远离他们,人们的中心性逐渐下降。
在这样一个小网络上,特征向量中心性表现得有些不稳定。如果你尝试添加或删除链接,你会发现网络中的微小变化可能会极大地改变中心性数值。在一个规模大得多的网络中,情况可能不会特别如此。
我们仍然没有解释为什么特征向量可能导致一个合理的中心性概念。成为特征向量意味着如果你计算:
matrix_times_vector(adjacency_matrix, eigenvector_centralities)
结果是 eigenvector_centralities 的标量倍数。
如果你看矩阵乘法的工作方式,matrix_times_vector 生成一个向量,其 ith 元素为:
dot(adjacency_matrix[i], eigenvector_centralities)
这正是连接到用户 i 的用户的特征向量中心性的总和。
换句话说,特征向量中心度是一个数字,每个用户一个,其值是他的邻居值的常数倍。在这种情况下,中心度意味着与自身中心度很高的人连接。你直接连接的中心度越高,你自己就越中心。这当然是一个循环定义——特征向量是打破循环性的方法。
另一种理解方式是通过思考find_eigenvector在这里的作用来理解这个问题。它首先为每个节点分配一个随机的中心度,然后重复以下两个步骤,直到过程收敛:
-
给每个节点一个新的中心度分数,该分数等于其邻居(旧的)中心度分数的总和。
-
重新调整中心度向量,使其大小为 1。
尽管其背后的数学可能一开始看起来有些难懂,但计算本身相对简单(不像介数中心度那样),甚至可以在非常大的图上执行。 (至少,如果你使用真正的线性代数库,那么在大型图上执行起来是很容易的。如果你使用我们的矩阵作为列表的实现,你会有些困难。)
有向图和 PageRank
DataSciencester 并没有得到很多关注,因此收入副总裁考虑从友谊模式转向背书模式。结果表明,没有人特别在意哪些数据科学家彼此是朋友,但技术招聘人员非常在意其他数据科学家受到其他数据科学家的尊重。
在这个新模型中,我们将跟踪不再代表互惠关系的背书(source, target),而是source背书target作为一个优秀的数据科学家(参见图 22-5)。
图 22-5. DataSciencester 的背书网络
我们需要考虑这种不对称性:
endorsements = [(0, 1), (1, 0), (0, 2), (2, 0), (1, 2),
(2, 1), (1, 3), (2, 3), (3, 4), (5, 4),
(5, 6), (7, 5), (6, 8), (8, 7), (8, 9)]
之后,我们可以轻松地找到most_endorsed数据科学家,并将这些信息卖给招聘人员:
from collections import Counter
endorsement_counts = Counter(target for source, target in endorsements)
然而,“背书数量”是一个容易操控的度量标准。你所需要做的就是创建假账户,并让它们为你背书。或者与你的朋友安排互相为对方背书。(就像用户 0、1 和 2 似乎已经做过的那样。)
更好的度量标准应考虑谁为你背书。来自背书颇多的人的背书应该比来自背书较少的人的背书更有价值。这就是 PageRank 算法的本质,它被谷歌用来根据其他网站链接到它们的网站来排名网站,以及那些链接到这些网站的网站,依此类推。
(如果这让你想起特征向量中心度的想法,那是正常的。)
一个简化版本看起来像这样:
-
网络中总共有 1.0(或 100%)的 PageRank。
-
最初,这个 PageRank 在节点之间均匀分布。
-
在每一步中,每个节点的大部分 PageRank 均匀分布在其出链之间。
-
在每个步骤中,每个节点的 PageRank 剩余部分均匀分布在所有节点之间。
import tqdm
def page_rank(users: List[User],
endorsements: List[Tuple[int, int]],
damping: float = 0.85,
num_iters: int = 100) -> Dict[int, float]:
# Compute how many people each person endorses
outgoing_counts = Counter(target for source, target in endorsements)
# Initially distribute PageRank evenly
num_users = len(users)
pr = {user.id : 1 / num_users for user in users}
# Small fraction of PageRank that each node gets each iteration
base_pr = (1 - damping) / num_users
for iter in tqdm.trange(num_iters):
next_pr = {user.id : base_pr for user in users} # start with base_pr
for source, target in endorsements:
# Add damped fraction of source pr to target
next_pr[target] += damping * pr[source] / outgoing_counts[source]
pr = next_pr
return pr
如果我们计算页面排名:
pr = page_rank(users, endorsements)
# Thor (user_id 4) has higher page rank than anyone else
assert pr[4] > max(page_rank
for user_id, page_rank in pr.items()
if user_id != 4)
PageRank(图 22-6)将用户 4(Thor)标识为排名最高的数据科学家。
图 22-6. 数据科学家网络按 PageRank 排序的大小
尽管 Thor 获得的认可少于用户 0、1 和 2(各有两个),但他的认可带来的排名来自他们的认可。此外,他的两个认可者仅认可了他一个人,这意味着他不必与任何其他人分享他们的排名。
进一步探索
-
除了我们使用的这些之外,还有许多其他的中心度概念(尽管我们使用的这些基本上是最受欢迎的)。
-
NetworkX 是用于网络分析的 Python 库。它有计算中心度和可视化图形的函数。
-
Gephi 是一款爱它或者恨它的基于 GUI 的网络可视化工具。
第二十五章:Chapter 23. 推荐系统
O nature, nature, why art thou so dishonest, as ever to send men with these false recommendations into the world!
Henry Fielding
Another common data problem is producing recommendations of some sort. Netflix recommends movies you might want to watch. Amazon recommends products you might want to buy. Twitter recommends users you might want to follow. In this chapter, we’ll look at several ways to use data to make recommendations.
In particular, we’ll look at the dataset of users_interests that we’ve used before:
users_interests = [
["Hadoop", "Big Data", "HBase", "Java", "Spark", "Storm", "Cassandra"],
["NoSQL", "MongoDB", "Cassandra", "HBase", "Postgres"],
["Python", "scikit-learn", "scipy", "numpy", "statsmodels", "pandas"],
["R", "Python", "statistics", "regression", "probability"],
["machine learning", "regression", "decision trees", "libsvm"],
["Python", "R", "Java", "C++", "Haskell", "programming languages"],
["statistics", "probability", "mathematics", "theory"],
["machine learning", "scikit-learn", "Mahout", "neural networks"],
["neural networks", "deep learning", "Big Data", "artificial intelligence"],
["Hadoop", "Java", "MapReduce", "Big Data"],
["statistics", "R", "statsmodels"],
["C++", "deep learning", "artificial intelligence", "probability"],
["pandas", "R", "Python"],
["databases", "HBase", "Postgres", "MySQL", "MongoDB"],
["libsvm", "regression", "support vector machines"]
]
And we’ll think about the problem of recommending new interests to a user based on her currently specified interests.
Manual Curation
Before the internet, when you needed book recommendations you would go to the library, where a librarian was available to suggest books that were relevant to your interests or similar to books you liked.
Given DataSciencester’s limited number of users and interests, it would be easy for you to spend an afternoon manually recommending interests for each user. But this method doesn’t scale particularly well, and it’s limited by your personal knowledge and imagination. (Not that I’m suggesting that your personal knowledge and imagination are limited.) So let’s think about what we can do with data.
Recommending What’s Popular
One easy approach is to simply recommend what’s popular:
from collections import Counter
popular_interests = Counter(interest
for user_interests in users_interests
for interest in user_interests)
which looks like:
[('Python', 4),
('R', 4),
('Java', 3),
('regression', 3),
('statistics', 3),
('probability', 3),
# ...
]
Having computed this, we can just suggest to a user the most popular interests that he’s not already interested in:
from typing import List, Tuple
def most_popular_new_interests(
user_interests: List[str],
max_results: int = 5) -> List[Tuple[str, int]]:
suggestions = [(interest, frequency)
for interest, frequency in popular_interests.most_common()
if interest not in user_interests]
return suggestions[:max_results]
So, if you are user 1, with interests:
["NoSQL", "MongoDB", "Cassandra", "HBase", "Postgres"]
then we’d recommend you:
[('Python', 4), ('R', 4), ('Java', 3), ('regression', 3), ('statistics', 3)]
If you are user 3, who’s already interested in many of those things, you’d instead get:
[('Java', 3), ('HBase', 3), ('Big Data', 3),
('neural networks', 2), ('Hadoop', 2)]
Of course, “lots of people are interested in Python, so maybe you should be too” is not the most compelling sales pitch. If someone is brand new to our site and we don’t know anything about them, that’s possibly the best we can do. Let’s see how we can do better by basing each user’s recommendations on her existing interests.
基于用户的协同过滤
One way of taking a user’s interests into account is to look for users who are somehow similar to her, and then suggest the things that those users are interested in.
In order to do that, we’ll need a way to measure how similar two users are. Here we’ll use cosine similarity, which we used in 第二十一章 to measure how similar two word vectors were.
We’ll apply this to vectors of 0s and 1s, each vector v representing one user’s interests. v[i] will be 1 if the user specified the ith interest, and 0 otherwise. Accordingly, “similar users” will mean “users whose interest vectors most nearly point in the same direction.” Users with identical interests will have similarity 1. Users with no identical interests will have similarity 0. Otherwise, the similarity will fall in between, with numbers closer to 1 indicating “very similar” and numbers closer to 0 indicating “not very similar.”
一个很好的开始是收集已知的兴趣,并(隐式地)为它们分配索引。我们可以通过使用集合推导来找到唯一的兴趣,并将它们排序成一个列表。结果列表中的第一个兴趣将是兴趣 0,依此类推:
unique_interests = sorted({interest
for user_interests in users_interests
for interest in user_interests})
这给我们一个以这样开始的列表:
assert unique_interests[:6] == [
'Big Data',
'C++',
'Cassandra',
'HBase',
'Hadoop',
'Haskell',
# ...
]
接下来,我们想为每个用户生成一个“兴趣”向量,其中包含 0 和 1。我们只需遍历unique_interests列表,如果用户具有每个兴趣,则替换为 1,否则为 0:
def make_user_interest_vector(user_interests: List[str]) -> List[int]:
"""
Given a list of interests, produce a vector whose ith element is 1
if unique_interests[i] is in the list, 0 otherwise
"""
return [1 if interest in user_interests else 0
for interest in unique_interests]
现在我们可以制作一个用户兴趣向量的列表:
user_interest_vectors = [make_user_interest_vector(user_interests)
for user_interests in users_interests]
现在,如果用户i指定了兴趣j,那么user_interest_vectors[i][j]等于 1,否则为 0。
因为我们有一个小数据集,计算所有用户之间的成对相似性是没有问题的:
from scratch.nlp import cosine_similarity
user_similarities = [[cosine_similarity(interest_vector_i, interest_vector_j)
for interest_vector_j in user_interest_vectors]
for interest_vector_i in user_interest_vectors]
之后,user_similarities[i][j]给出了用户i和j之间的相似性:
# Users 0 and 9 share interests in Hadoop, Java, and Big Data
assert 0.56 < user_similarities[0][9] < 0.58, "several shared interests"
# Users 0 and 8 share only one interest: Big Data
assert 0.18 < user_similarities[0][8] < 0.20, "only one shared interest"
特别地,user_similarities[i]是用户i与每个其他用户的相似性向量。我们可以使用这个来编写一个函数,找出与给定用户最相似的用户。我们会确保不包括用户本身,也不包括任何相似性为零的用户。并且我们会按照相似性从高到低对结果进行排序:
def most_similar_users_to(user_id: int) -> List[Tuple[int, float]]:
pairs = [(other_user_id, similarity) # Find other
for other_user_id, similarity in # users with
enumerate(user_similarities[user_id]) # nonzero
if user_id != other_user_id and similarity > 0] # similarity.
return sorted(pairs, # Sort them
key=lambda pair: pair[-1], # most similar
reverse=True) # first.
例如,如果我们调用most_similar_users_to(0),我们会得到:
[(9, 0.5669467095138409),
(1, 0.3380617018914066),
(8, 0.1889822365046136),
(13, 0.1690308509457033),
(5, 0.1543033499620919)]
我们如何利用这个来向用户建议新的兴趣?对于每个兴趣,我们可以简单地加上对它感兴趣的其他用户的用户相似性:
from collections import defaultdict
def user_based_suggestions(user_id: int,
include_current_interests: bool = False):
# Sum up the similarities
suggestions: Dict[str, float] = defaultdict(float)
for other_user_id, similarity in most_similar_users_to(user_id):
for interest in users_interests[other_user_id]:
suggestions[interest] += similarity
# Convert them to a sorted list
suggestions = sorted(suggestions.items(),
key=lambda pair: pair[-1], # weight
reverse=True)
# And (maybe) exclude already interests
if include_current_interests:
return suggestions
else:
return [(suggestion, weight)
for suggestion, weight in suggestions
if suggestion not in users_interests[user_id]]
如果我们调用user_based_suggestions(0),那么前几个建议的兴趣是:
[('MapReduce', 0.5669467095138409),
('MongoDB', 0.50709255283711),
('Postgres', 0.50709255283711),
('NoSQL', 0.3380617018914066),
('neural networks', 0.1889822365046136),
('deep learning', 0.1889822365046136),
('artificial intelligence', 0.1889822365046136),
#...
]
对于那些声称兴趣是“大数据”和数据库相关的人来说,这些看起来是相当不错的建议。(权重本质上没有意义;我们只是用它们来排序。)
当项目数量变得非常大时,这种方法效果不佳。回想一下第十二章中的维度诅咒 —— 在高维向量空间中,大多数向量相距甚远(并且指向非常不同的方向)。也就是说,当兴趣的数量很多时,对于给定用户,“最相似的用户”可能完全不相似。
想象一个像 Amazon.com 这样的网站,我在过去几十年里购买了成千上万件物品。你可以基于购买模式尝试识别与我类似的用户,但在全世界范围内,几乎没有人的购买历史看起来像我的。无论我的“最相似”的购物者是谁,他可能与我完全不相似,他的购买几乎肯定不会提供好的推荐。
基于物品的协同过滤
另一种方法是直接计算兴趣之间的相似性。然后我们可以通过聚合与她当前兴趣相似的兴趣来为每个用户生成建议。
要开始,我们将希望转置我们的用户-兴趣矩阵,以便行对应于兴趣,列对应于用户:
interest_user_matrix = [[user_interest_vector[j]
for user_interest_vector in user_interest_vectors]
for j, _ in enumerate(unique_interests)]
这是什么样子?interest_user_matrix 的第 j 行就是 user_interest_matrix 的第 j 列。也就是说,对于每个具有该兴趣的用户,它的值为 1,对于每个没有该兴趣的用户,它的值为 0。
例如,unique_interests[0] 是大数据,所以 interest_user_matrix[0] 是:
[1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0]
因为用户 0、8 和 9 表示对大数据感兴趣。
现在我们可以再次使用余弦相似度。如果完全相同的用户对两个主题感兴趣,它们的相似度将为 1。如果没有两个用户对两个主题感兴趣,它们的相似度将为 0:
interest_similarities = [[cosine_similarity(user_vector_i, user_vector_j)
for user_vector_j in interest_user_matrix]
for user_vector_i in interest_user_matrix]
例如,我们可以使用以下方法找到与大数据(兴趣 0)最相似的兴趣:
def most_similar_interests_to(interest_id: int):
similarities = interest_similarities[interest_id]
pairs = [(unique_interests[other_interest_id], similarity)
for other_interest_id, similarity in enumerate(similarities)
if interest_id != other_interest_id and similarity > 0]
return sorted(pairs,
key=lambda pair: pair[-1],
reverse=True)
这表明以下类似的兴趣:
[('Hadoop', 0.8164965809277261),
('Java', 0.6666666666666666),
('MapReduce', 0.5773502691896258),
('Spark', 0.5773502691896258),
('Storm', 0.5773502691896258),
('Cassandra', 0.4082482904638631),
('artificial intelligence', 0.4082482904638631),
('deep learning', 0.4082482904638631),
('neural networks', 0.4082482904638631),
('HBase', 0.3333333333333333)]
现在我们可以通过累加与其相似的兴趣的相似性来为用户创建推荐:
def item_based_suggestions(user_id: int,
include_current_interests: bool = False):
# Add up the similar interests
suggestions = defaultdict(float)
user_interest_vector = user_interest_vectors[user_id]
for interest_id, is_interested in enumerate(user_interest_vector):
if is_interested == 1:
similar_interests = most_similar_interests_to(interest_id)
for interest, similarity in similar_interests:
suggestions[interest] += similarity
# Sort them by weight
suggestions = sorted(suggestions.items(),
key=lambda pair: pair[-1],
reverse=True)
if include_current_interests:
return suggestions
else:
return [(suggestion, weight)
for suggestion, weight in suggestions
if suggestion not in users_interests[user_id]]
对于用户 0,这将生成以下(看起来合理的)推荐:
[('MapReduce', 1.861807319565799),
('Postgres', 1.3164965809277263),
('MongoDB', 1.3164965809277263),
('NoSQL', 1.2844570503761732),
('programming languages', 0.5773502691896258),
('MySQL', 0.5773502691896258),
('Haskell', 0.5773502691896258),
('databases', 0.5773502691896258),
('neural networks', 0.4082482904638631),
('deep learning', 0.4082482904638631),
('C++', 0.4082482904638631),
('artificial intelligence', 0.4082482904638631),
('Python', 0.2886751345948129),
('R', 0.2886751345948129)]
矩阵分解
正如我们所看到的,我们可以将用户的偏好表示为一个 [num_users, num_items] 的矩阵,其中 1 表示喜欢的项目,0 表示不喜欢的项目。
有时您实际上可能有数值型的 评分;例如,当您写亚马逊评论时,您为物品分配了从 1 到 5 星的评分。您仍然可以通过数字在一个 [num_users, num_items] 的矩阵中表示这些(暂时忽略未评分项目的问题)。
在本节中,我们假设已经有了这样的评分数据,并尝试学习一个能够预测给定用户和项目评分的模型。
解决这个问题的一种方法是假设每个用户都有一些潜在的“类型”,可以表示为一组数字向量,而每个项目同样也有一些潜在的“类型”。
如果将用户类型表示为 [num_users, dim] 矩阵,将项目类型的转置表示为 [dim, num_items] 矩阵,则它们的乘积是一个 [num_users, num_items] 矩阵。因此,构建这样一个模型的一种方式是将偏好矩阵“因子化”为用户矩阵和项目矩阵的乘积。
(也许这种潜在类型的想法会让你想起我们在 第二十一章 中开发的词嵌入。记住这个想法。)
而不是使用我们虚构的 10 用户数据集,我们将使用 MovieLens 100k 数据集,其中包含许多用户对许多电影的评分,评分从 0 到 5 不等。每个用户只对少数电影进行了评分。我们将尝试构建一个系统,可以预测任意给定的(用户,电影)对的评分。我们将训练它以在每个用户评分的电影上表现良好;希望它能推广到用户未评分的电影。
首先,让我们获取数据集。您可以从 http://files.grouplens.org/datasets/movielens/ml-100k.zip 下载它。
解压并提取文件;我们仅使用其中两个:
# This points to the current directory, modify if your files are elsewhere.
MOVIES = "u.item" # pipe-delimited: movie_id|title|...
RATINGS = "u.data" # tab-delimited: user_id, movie_id, rating, timestamp
常见情况下,我们会引入 NamedTuple 来使工作更加简便:
from typing import NamedTuple
class Rating(NamedTuple):
user_id: str
movie_id: str
rating: float
注意
电影 ID 和用户 ID 实际上是整数,但它们不是连续的,这意味着如果我们将它们作为整数处理,将会有很多浪费的维度(除非我们重新编号所有内容)。因此,为了简化起见,我们将它们视为字符串处理。
现在让我们读取数据并探索它。电影文件是管道分隔的,并且有许多列。我们只关心前两列,即 ID 和标题:
import csv
# We specify this encoding to avoid a UnicodeDecodeError.
# See: https://stackoverflow.com/a/53136168/1076346.
with open(MOVIES, encoding="iso-8859-1") as f:
reader = csv.reader(f, delimiter="|")
movies = {movie_id: title for movie_id, title, *_ in reader}
评分文件是制表符分隔的,包含四列:user_id、movie_id、评分(1 到 5),以及timestamp。我们将忽略时间戳,因为我们不需要它:
# Create a list of [Rating]
with open(RATINGS, encoding="iso-8859-1") as f:
reader = csv.reader(f, delimiter="\t")
ratings = [Rating(user_id, movie_id, float(rating))
for user_id, movie_id, rating, _ in reader]
# 1682 movies rated by 943 users
assert len(movies) == 1682
assert len(list({rating.user_id for rating in ratings})) == 943
有很多有趣的探索性分析可以在这些数据上进行;例如,您可能对星球大战电影的平均评分感兴趣(该数据集来自 1998 年,比星球大战:幽灵的威胁晚一年):
import re
# Data structure for accumulating ratings by movie_id
star_wars_ratings = {movie_id: []
for movie_id, title in movies.items()
if re.search("Star Wars|Empire Strikes|Jedi", title)}
# Iterate over ratings, accumulating the Star Wars ones
for rating in ratings:
if rating.movie_id in star_wars_ratings:
star_wars_ratings[rating.movie_id].append(rating.rating)
# Compute the average rating for each movie
avg_ratings = [(sum(title_ratings) / len(title_ratings), movie_id)
for movie_id, title_ratings in star_wars_ratings.items()]
# And then print them in order
for avg_rating, movie_id in sorted(avg_ratings, reverse=True):
print(f"{avg_rating:.2f} {movies[movie_id]}")
它们都评分很高:
4.36 Star Wars (1977)
4.20 Empire Strikes Back, The (1980)
4.01 Return of the Jedi (1983)
所以让我们尝试设计一个模型来预测这些评分。作为第一步,让我们将评分数据分成训练集、验证集和测试集:
import random
random.seed(0)
random.shuffle(ratings)
split1 = int(len(ratings) * 0.7)
split2 = int(len(ratings) * 0.85)
train = ratings[:split1] # 70% of the data
validation = ratings[split1:split2] # 15% of the data
test = ratings[split2:] # 15% of the data
拥有一个简单的基线模型总是好的,并确保我们的模型比它表现更好。这里一个简单的基线模型可能是“预测平均评分”。我们将使用均方误差作为我们的指标,所以让我们看看基线在我们的测试集上表现如何:
avg_rating = sum(rating.rating for rating in train) / len(train)
baseline_error = sum((rating.rating - avg_rating) ** 2
for rating in test) / len(test)
# This is what we hope to do better than
assert 1.26 < baseline_error < 1.27
给定我们的嵌入,预测的评分由用户嵌入和电影嵌入的矩阵乘积给出。对于给定的用户和电影,该值只是对应嵌入的点积。
所以让我们从创建嵌入开始。我们将它们表示为dict,其中键是 ID,值是向量,这样可以轻松地检索给定 ID 的嵌入:
from scratch.deep_learning import random_tensor
EMBEDDING_DIM = 2
# Find unique ids
user_ids = {rating.user_id for rating in ratings}
movie_ids = {rating.movie_id for rating in ratings}
# Then create a random vector per id
user_vectors = {user_id: random_tensor(EMBEDDING_DIM)
for user_id in user_ids}
movie_vectors = {movie_id: random_tensor(EMBEDDING_DIM)
for movie_id in movie_ids}
到目前为止,我们应该相当擅长编写训练循环:
from typing import List
import tqdm
from scratch.linear_algebra import dot
def loop(dataset: List[Rating],
learning_rate: float = None) -> None:
with tqdm.tqdm(dataset) as t:
loss = 0.0
for i, rating in enumerate(t):
movie_vector = movie_vectors[rating.movie_id]
user_vector = user_vectors[rating.user_id]
predicted = dot(user_vector, movie_vector)
error = predicted - rating.rating
loss += error ** 2
if learning_rate is not None:
# predicted = m_0 * u_0 + ... + m_k * u_k
# So each u_j enters output with coefficent m_j
# and each m_j enters output with coefficient u_j
user_gradient = [error * m_j for m_j in movie_vector]
movie_gradient = [error * u_j for u_j in user_vector]
# Take gradient steps
for j in range(EMBEDDING_DIM):
user_vector[j] -= learning_rate * user_gradient[j]
movie_vector[j] -= learning_rate * movie_gradient[j]
t.set_description(f"avg loss: {loss / (i + 1)}")
现在我们可以训练我们的模型(即找到最优的嵌入)。对我来说,如果我每个时期都稍微降低学习率,效果最好:
learning_rate = 0.05
for epoch in range(20):
learning_rate *= 0.9
print(epoch, learning_rate)
loop(train, learning_rate=learning_rate)
loop(validation)
loop(test)
这个模型很容易过拟合训练集。我在测试集上的平均损失是大约 0.89,这时EMBEDDING_DIM=2的情况下取得最佳结果。
注意
如果您想要更高维度的嵌入,您可以尝试像我们在“正则化”中使用的正则化。特别是,在每次梯度更新时,您可以将权重收缩至 0 附近。但我没能通过这种方式获得更好的结果。
现在,检查学习到的向量。没有理由期望这两个组件特别有意义,因此我们将使用主成分分析:
from scratch.working_with_data import pca, transform
original_vectors = [vector for vector in movie_vectors.values()]
components = pca(original_vectors, 2)
让我们将我们的向量转换为表示主成分,并加入电影 ID 和平均评分:
ratings_by_movie = defaultdict(list)
for rating in ratings:
ratings_by_movie[rating.movie_id].append(rating.rating)
vectors = [
(movie_id,
sum(ratings_by_movie[movie_id]) / len(ratings_by_movie[movie_id]),
movies[movie_id],
vector)
for movie_id, vector in zip(movie_vectors.keys(),
transform(original_vectors, components))
]
# Print top 25 and bottom 25 by first principal component
print(sorted(vectors, key=lambda v: v[-1][0])[:25])
print(sorted(vectors, key=lambda v: v[-1][0])[-25:])
前 25 个电影评分都很高,而后 25 个大部分是低评分的(或在训练数据中未评级),这表明第一个主成分主要捕捉了“这部电影有多好?”
对于我来说,很难理解第二个组件的意义;而且二维嵌入的表现只比一维嵌入略好,这表明第二个组件捕捉到的可能是非常微妙的内容。(可以推测,在较大的 MovieLens 数据集中可能有更有趣的事情发生。)
进一步探索
-
惊喜是一个用于“构建和分析推荐系统”的 Python 库,似乎相当受欢迎且更新及时。
-
Netflix Prize 是一个相当有名的比赛,旨在构建更好的系统,向 Netflix 用户推荐电影。
第二十四章:数据库和 SQL
记忆是人类最好的朋友,也是最坏的敌人。
Gilbert Parker
您需要的数据通常存储在数据库中,这些系统专门设计用于高效存储和查询数据。这些大部分是关系型数据库,如 PostgreSQL、MySQL 和 SQL Server,它们将数据存储在表中,并通常使用结构化查询语言(SQL)进行查询,这是一种用于操作数据的声明性语言。
SQL 是数据科学家工具包中非常重要的一部分。在本章中,我们将创建 NotQuiteABase,这是 Python 实现的一个几乎不是数据库的东西。我们还将介绍 SQL 的基础知识,并展示它们在我们的几乎不是数据库中的工作方式,这是我能想到的最“从头开始”的方式,帮助您理解它们在做什么。我希望在 NotQuiteABase 中解决问题将使您对如何使用 SQL 解决相同问题有一个良好的感觉。
创建表和插入
关系数据库是表的集合,以及它们之间的关系。表只是行的集合,与我们一直在处理的一些矩阵类似。然而,表还有一个固定的模式,包括列名和列类型。
例如,想象一个包含每个用户的user_id、name和num_friends的users数据集:
users = [[0, "Hero", 0],
[1, "Dunn", 2],
[2, "Sue", 3],
[3, "Chi", 3]]
在 SQL 中,我们可以这样创建这个表:
CREATE TABLE users (
user_id INT NOT NULL,
name VARCHAR(200),
num_friends INT);
注意我们指定了user_id和num_friends必须是整数(并且user_id不允许为NULL,表示缺少值,类似于我们的None),而name应该是长度不超过 200 的字符串。我们将类似地使用 Python 类型。
注意
SQL 几乎完全不区分大小写和缩进。这里的大写和缩进风格是我喜欢的风格。如果您开始学习 SQL,您肯定会遇到其他样式不同的例子。
您可以使用INSERT语句插入行:
INSERT INTO users (user_id, name, num_friends) VALUES (0, 'Hero', 0);
还要注意 SQL 语句需要以分号结尾,并且 SQL 中字符串需要用单引号括起来。
在 NotQuiteABase 中,您将通过指定类似的模式来创建一个Table。然后,要插入一行,您将使用表的insert方法,该方法接受一个与表列名顺序相同的list行值。
在幕后,我们将每一行都存储为一个从列名到值的dict。一个真正的数据库永远不会使用这样浪费空间的表示方法,但这样做将使得 NotQuiteABase 更容易处理。
我们将 NotQuiteABase Table实现为一个巨大的类,我们将一次实现一个方法。让我们先把导入和类型别名处理掉:
from typing import Tuple, Sequence, List, Any, Callable, Dict, Iterator
from collections import defaultdict
# A few type aliases we'll use later
Row = Dict[str, Any] # A database row
WhereClause = Callable[[Row], bool] # Predicate for a single row
HavingClause = Callable[[List[Row]], bool] # Predicate over multiple rows
让我们从构造函数开始。要创建一个 NotQuiteABase 表,我们需要传入列名列表和列类型列表,就像您在创建 SQL 数据库中的表时所做的一样:
class Table:
def __init__(self, columns: List[str], types: List[type]) -> None:
assert len(columns) == len(types), "# of columns must == # of types"
self.columns = columns # Names of columns
self.types = types # Data types of columns
self.rows: List[Row] = [] # (no data yet)
我们将添加一个帮助方法来获取列的类型:
def col2type(self, col: str) -> type:
idx = self.columns.index(col) # Find the index of the column,
return self.types[idx] # and return its type.
我们将添加一个 insert 方法来检查您要插入的值是否有效。特别是,您必须提供正确数量的值,并且每个值必须是正确的类型(或 None):
def insert(self, values: list) -> None:
# Check for right # of values
if len(values) != len(self.types):
raise ValueError(f"You need to provide {len(self.types)} values")
# Check for right types of values
for value, typ3 in zip(values, self.types):
if not isinstance(value, typ3) and value is not None:
raise TypeError(f"Expected type {typ3} but got {value}")
# Add the corresponding dict as a "row"
self.rows.append(dict(zip(self.columns, values)))
在实际的 SQL 数据库中,你需要明确指定任何给定列是否允许包含空值 (None);为了简化我们的生活,我们只会说任何列都可以。
我们还将引入一些 dunder 方法,允许我们将表视为一个 List[Row],我们主要用于测试我们的代码:
def __getitem__(self, idx: int) -> Row:
return self.rows[idx]
def __iter__(self) -> Iterator[Row]:
return iter(self.rows)
def __len__(self) -> int:
return len(self.rows)
我们将添加一个方法来漂亮地打印我们的表:
def __repr__(self):
"""Pretty representation of the table: columns then rows"""
rows = "\n".join(str(row) for row in self.rows)
return f"{self.columns}\n{rows}"
现在我们可以创建我们的 Users 表:
# Constructor requires column names and types
users = Table(['user_id', 'name', 'num_friends'], [int, str, int])
users.insert([0, "Hero", 0])
users.insert([1, "Dunn", 2])
users.insert([2, "Sue", 3])
users.insert([3, "Chi", 3])
users.insert([4, "Thor", 3])
users.insert([5, "Clive", 2])
users.insert([6, "Hicks", 3])
users.insert([7, "Devin", 2])
users.insert([8, "Kate", 2])
users.insert([9, "Klein", 3])
users.insert([10, "Jen", 1])
如果您现在 print(users),您将看到:
['user_id', 'name', 'num_friends']
{'user_id': 0, 'name': 'Hero', 'num_friends': 0}
{'user_id': 1, 'name': 'Dunn', 'num_friends': 2}
{'user_id': 2, 'name': 'Sue', 'num_friends': 3}
...
列表样的 API 使得编写测试变得容易:
assert len(users) == 11
assert users[1]['name'] == 'Dunn'
我们还有更多功能要添加。
更新
有时您需要更新已经在数据库中的数据。例如,如果 Dunn 又交了一个朋友,您可能需要这样做:
UPDATE users
SET num_friends = 3
WHERE user_id = 1;
关键特性包括:
-
要更新哪个表
-
要更新哪些行
-
要更新哪些字段
-
它们的新值应该是什么
我们将在 NotQuiteABase 中添加一个类似的 update 方法。它的第一个参数将是一个 dict,其键是要更新的列,其值是这些字段的新值。其第二个(可选)参数应该是一个 predicate,对于应该更新的行返回 True,否则返回 False:
def update(self,
updates: Dict[str, Any],
predicate: WhereClause = lambda row: True):
# First make sure the updates have valid names and types
for column, new_value in updates.items():
if column not in self.columns:
raise ValueError(f"invalid column: {column}")
typ3 = self.col2type(column)
if not isinstance(new_value, typ3) and new_value is not None:
raise TypeError(f"expected type {typ3}, but got {new_value}")
# Now update
for row in self.rows:
if predicate(row):
for column, new_value in updates.items():
row[column] = new_value
之后我们可以简单地这样做:
assert users[1]['num_friends'] == 2 # Original value
users.update({'num_friends' : 3}, # Set num_friends = 3
lambda row: row['user_id'] == 1) # in rows where user_id == 1
assert users[1]['num_friends'] == 3 # Updated value
删除
在 SQL 中从表中删除行有两种方法。危险的方式会删除表中的每一行:
DELETE FROM users;
较不危险的方式添加了一个 WHERE 子句,并且仅删除满足特定条件的行:
DELETE FROM users WHERE user_id = 1;
将此功能添加到我们的 Table 中很容易:
def delete(self, predicate: WhereClause = lambda row: True) -> None:
"""Delete all rows matching predicate"""
self.rows = [row for row in self.rows if not predicate(row)]
如果您提供一个 predicate 函数(即 WHERE 子句),这将仅删除满足它的行。如果您不提供一个,那么默认的 predicate 总是返回 True,并且您将删除每一行。
例如:
# We're not actually going to run these
users.delete(lambda row: row["user_id"] == 1) # Deletes rows with user_id == 1
users.delete() # Deletes every row
选择
通常你不直接检查 SQL 表。相反,您使用 SELECT 语句查询它们:
SELECT * FROM users; -- get the entire contents
SELECT * FROM users LIMIT 2; -- get the first two rows
SELECT user_id FROM users; -- only get specific columns
SELECT user_id FROM users WHERE name = 'Dunn'; -- only get specific rows
您还可以使用 SELECT 语句计算字段:
SELECT LENGTH(name) AS name_length FROM users;
我们将给我们的 Table 类添加一个 select 方法,该方法返回一个新的 Table。该方法接受两个可选参数:
-
keep_columns指定结果中要保留的列名。如果您没有提供它,结果将包含所有列。 -
additional_columns是一个字典,其键是新列名,值是指定如何计算新列值的函数。我们将查看这些函数的类型注解来确定新列的类型,因此这些函数需要有注解的返回类型。
如果你没有提供它们中的任何一个,你将简单地得到表的一个副本:
def select(self,
keep_columns: List[str] = None,
additional_columns: Dict[str, Callable] = None) -> 'Table':
if keep_columns is None: # If no columns specified,
keep_columns = self.columns # return all columns
if additional_columns is None:
additional_columns = {}
# New column names and types
new_columns = keep_columns + list(additional_columns.keys())
keep_types = [self.col2type(col) for col in keep_columns]
# This is how to get the return type from a type annotation.
# It will crash if `calculation` doesn't have a return type.
add_types = [calculation.__annotations__['return']
for calculation in additional_columns.values()]
# Create a new table for results
new_table = Table(new_columns, keep_types + add_types)
for row in self.rows:
new_row = [row[column] for column in keep_columns]
for column_name, calculation in additional_columns.items():
new_row.append(calculation(row))
new_table.insert(new_row)
return new_table
注意
还记得在第二章中我们说过类型注解实际上什么也不做吗?好吧,这里是反例。但是看看我们必须经历多么复杂的过程才能得到它们。
我们的select返回一个新的Table,而典型的 SQL SELECT仅产生某种临时结果集(除非您将结果明确插入到表中)。
我们还需要where和limit方法。这两者都很简单:
def where(self, predicate: WhereClause = lambda row: True) -> 'Table':
"""Return only the rows that satisfy the supplied predicate"""
where_table = Table(self.columns, self.types)
for row in self.rows:
if predicate(row):
values = [row[column] for column in self.columns]
where_table.insert(values)
return where_table
def limit(self, num_rows: int) -> 'Table':
"""Return only the first `num_rows` rows"""
limit_table = Table(self.columns, self.types)
for i, row in enumerate(self.rows):
if i >= num_rows:
break
values = [row[column] for column in self.columns]
limit_table.insert(values)
return limit_table
然后我们可以轻松地构造与前面的 SQL 语句相等的 NotQuiteABase 等效语句:
# SELECT * FROM users;
all_users = users.select()
assert len(all_users) == 11
# SELECT * FROM users LIMIT 2;
two_users = users.limit(2)
assert len(two_users) == 2
# SELECT user_id FROM users;
just_ids = users.select(keep_columns=["user_id"])
assert just_ids.columns == ['user_id']
# SELECT user_id FROM users WHERE name = 'Dunn';
dunn_ids = (
users
.where(lambda row: row["name"] == "Dunn")
.select(keep_columns=["user_id"])
)
assert len(dunn_ids) == 1
assert dunn_ids[0] == {"user_id": 1}
# SELECT LENGTH(name) AS name_length FROM users;
def name_length(row) -> int: return len(row["name"])
name_lengths = users.select(keep_columns=[],
additional_columns = {"name_length": name_length})
assert name_lengths[0]['name_length'] == len("Hero")
注意,对于多行“流畅”查询,我们必须将整个查询包装在括号中。
GROUP BY
另一个常见的 SQL 操作是GROUP BY,它将具有指定列中相同值的行分组在一起,并生成诸如MIN、MAX、COUNT和SUM之类的聚合值。
例如,您可能希望找到每个可能的名称长度的用户数和最小的user_id:
SELECT LENGTH(name) as name_length,
MIN(user_id) AS min_user_id,
COUNT(*) AS num_users
FROM users
GROUP BY LENGTH(name);
我们选择的每个字段都需要在GROUP BY子句(其中name_length是)或聚合计算(min_user_id和num_users是)中。
SQL 还支持一个HAVING子句,其行为类似于WHERE子句,只是其过滤器应用于聚合(而WHERE将在聚合之前过滤行)。
您可能想知道以特定字母开头的用户名的平均朋友数量,但仅查看其对应平均值大于 1 的字母的结果。(是的,这些示例中有些是人为构造的。)
SELECT SUBSTR(name, 1, 1) AS first_letter,
AVG(num_friends) AS avg_num_friends
FROM users
GROUP BY SUBSTR(name, 1, 1)
HAVING AVG(num_friends) > 1;
注意
不同数据库中用于处理字符串的函数各不相同;一些数据库可能会使用SUBSTRING或其他东西。
您还可以计算整体聚合值。在这种情况下,您可以省略GROUP BY:
SELECT SUM(user_id) as user_id_sum
FROM users
WHERE user_id > 1;
要将此功能添加到 NotQuiteABase 的Table中,我们将添加一个group_by方法。它接受您要按组分组的列的名称,您要在每个组上运行的聚合函数的字典,以及一个可选的名为having的谓词,该谓词对多行进行操作。
然后执行以下步骤:
-
创建一个
defaultdict来将tuple(按分组值)映射到行(包含分组值的行)。请记住,您不能使用列表作为dict的键;您必须使用元组。 -
遍历表的行,填充
defaultdict。 -
创建一个具有正确输出列的新表。
-
遍历
defaultdict并填充输出表,应用having过滤器(如果有)。
def group_by(self,
group_by_columns: List[str],
aggregates: Dict[str, Callable],
having: HavingClause = lambda group: True) -> 'Table':
grouped_rows = defaultdict(list)
# Populate groups
for row in self.rows:
key = tuple(row[column] for column in group_by_columns)
grouped_rows[key].append(row)
# Result table consists of group_by columns and aggregates
new_columns = group_by_columns + list(aggregates.keys())
group_by_types = [self.col2type(col) for col in group_by_columns]
aggregate_types = [agg.__annotations__['return']
for agg in aggregates.values()]
result_table = Table(new_columns, group_by_types + aggregate_types)
for key, rows in grouped_rows.items():
if having(rows):
new_row = list(key)
for aggregate_name, aggregate_fn in aggregates.items():
new_row.append(aggregate_fn(rows))
result_table.insert(new_row)
return result_table
注意
实际的数据库几乎肯定会以更有效的方式执行此操作。
同样,让我们看看如何执行与前面的 SQL 语句等效的操作。name_length指标是:
def min_user_id(rows) -> int:
return min(row["user_id"] for row in rows)
def length(rows) -> int:
return len(rows)
stats_by_length = (
users
.select(additional_columns={"name_length" : name_length})
.group_by(group_by_columns=["name_length"],
aggregates={"min_user_id" : min_user_id,
"num_users" : length})
)
first_letter指标是:
def first_letter_of_name(row: Row) -> str:
return row["name"][0] if row["name"] else ""
def average_num_friends(rows: List[Row]) -> float:
return sum(row["num_friends"] for row in rows) / len(rows)
def enough_friends(rows: List[Row]) -> bool:
return average_num_friends(rows) > 1
avg_friends_by_letter = (
users
.select(additional_columns={'first_letter' : first_letter_of_name})
.group_by(group_by_columns=['first_letter'],
aggregates={"avg_num_friends" : average_num_friends},
having=enough_friends)
)
user_id_sum是:
def sum_user_ids(rows: List[Row]) -> int:
return sum(row["user_id"] for row in rows)
user_id_sum = (
users
.where(lambda row: row["user_id"] > 1)
.group_by(group_by_columns=[],
aggregates={ "user_id_sum" : sum_user_ids })
)
ORDER BY
经常,您可能希望对结果进行排序。例如,您可能希望知道用户的(按字母顺序)前两个名称:
SELECT * FROM users
ORDER BY name
LIMIT 2;
这很容易通过给我们的Table添加一个order_by方法来实现,该方法接受一个order函数来实现:
def order_by(self, order: Callable[[Row], Any]) -> 'Table':
new_table = self.select() # make a copy
new_table.rows.sort(key=order)
return new_table
然后我们可以像这样使用它们:
friendliest_letters = (
avg_friends_by_letter
.order_by(lambda row: -row["avg_num_friends"])
.limit(4)
)
SQL 的ORDER BY允许您为每个排序字段指定ASC(升序)或DESC(降序);在这里,我们必须将其嵌入到我们的order函数中。
JOIN
关系型数据库表通常是规范化的,这意味着它们被组织成最小化冗余。例如,当我们在 Python 中处理用户的兴趣时,我们可以为每个用户分配一个包含其兴趣的list。
SQL 表通常不能包含列表,所以典型的解决方案是创建第二个表,称为user_interests,包含user_id和interest之间的一对多关系。在 SQL 中,你可以这样做:
CREATE TABLE user_interests (
user_id INT NOT NULL,
interest VARCHAR(100) NOT NULL
);
而在 NotQuiteABase 中,你需要创建这样一个表:
user_interests = Table(['user_id', 'interest'], [int, str])
user_interests.insert([0, "SQL"])
user_interests.insert([0, "NoSQL"])
user_interests.insert([2, "SQL"])
user_interests.insert([2, "MySQL"])
注意
仍然存在大量冗余 —— 兴趣“SQL”存储在两个不同的地方。在实际数据库中,您可能会将user_id和interest_id存储在user_interests表中,然后创建第三个表interests,将interest_id映射到interest,这样您只需存储兴趣名称一次。但这会使我们的示例变得比必要的复杂。
当我们的数据分布在不同的表中时,我们如何分析它?通过将表进行JOIN。JOIN将左表中的行与右表中相应的行组合在一起,其中“相应”的含义基于我们如何指定连接的方式。
例如,要查找对 SQL 感兴趣的用户,你会这样查询:
SELECT users.name
FROM users
JOIN user_interests
ON users.user_id = user_interests.user_id
WHERE user_interests.interest = 'SQL'
JOIN指示,对于users中的每一行,我们应该查看user_id并将该行与包含相同user_id的user_interests中的每一行关联起来。
注意,我们必须指定要JOIN的表和要ON连接的列。这是一个INNER JOIN,它根据指定的连接条件返回匹配的行组合(仅限匹配的行组合)。
还有一种LEFT JOIN,除了匹配行的组合外,还返回每个左表行的未匹配行(在这种情况下,右表应该出现的字段都是NULL)。
使用LEFT JOIN,很容易统计每个用户的兴趣数量:
SELECT users.id, COUNT(user_interests.interest) AS num_interests
FROM users
LEFT JOIN user_interests
ON users.user_id = user_interests.user_id
LEFT JOIN确保没有兴趣的用户仍然在连接数据集中具有行(user_interests字段的值为NULL),而COUNT仅计算非NULL值。
NotQuiteABase 的join实现将更为严格 —— 它仅仅在两个表中存在共同列时进行连接。即便如此,编写起来也不是件简单的事情:
def join(self, other_table: 'Table', left_join: bool = False) -> 'Table':
join_on_columns = [c for c in self.columns # columns in
if c in other_table.columns] # both tables
additional_columns = [c for c in other_table.columns # columns only
if c not in join_on_columns] # in right table
# all columns from left table + additional_columns from right table
new_columns = self.columns + additional_columns
new_types = self.types + [other_table.col2type(col)
for col in additional_columns]
join_table = Table(new_columns, new_types)
for row in self.rows:
def is_join(other_row):
return all(other_row[c] == row[c] for c in join_on_columns)
other_rows = other_table.where(is_join).rows
# Each other row that matches this one produces a result row.
for other_row in other_rows:
join_table.insert([row[c] for c in self.columns] +
[other_row[c] for c in additional_columns])
# If no rows match and it's a left join, output with Nones.
if left_join and not other_rows:
join_table.insert([row[c] for c in self.columns] +
[None for c in additional_columns])
return join_table
因此,我们可以找到对 SQL 感兴趣的用户:
sql_users = (
users
.join(user_interests)
.where(lambda row: row["interest"] == "SQL")
.select(keep_columns=["name"])
)
我们可以通过以下方式获得兴趣计数:
def count_interests(rows: List[Row]) -> int:
"""counts how many rows have non-None interests"""
return len([row for row in rows if row["interest"] is not None])
user_interest_counts = (
users
.join(user_interests, left_join=True)
.group_by(group_by_columns=["user_id"],
aggregates={"num_interests" : count_interests })
)
在 SQL 中,还有一种RIGHT JOIN,它保留来自右表且没有匹配的行,还有一种FULL OUTER JOIN,它保留来自两个表且没有匹配的行。我们不会实现其中任何一种。
子查询
在 SQL 中,您可以从(和JOIN)查询的结果中SELECT,就像它们是表一样。因此,如果您想找到任何对 SQL 感兴趣的人中最小的user_id,您可以使用子查询。(当然,您也可以使用JOIN执行相同的计算,但这不会说明子查询。)
SELECT MIN(user_id) AS min_user_id FROM
(SELECT user_id FROM user_interests WHERE interest = 'SQL') sql_interests;
鉴于我们设计的 NotQuiteABase 的方式,我们可以免费获得这些功能。(我们的查询结果是实际的表。)
likes_sql_user_ids = (
user_interests
.where(lambda row: row["interest"] == "SQL")
.select(keep_columns=['user_id'])
)
likes_sql_user_ids.group_by(group_by_columns=[],
aggregates={ "min_user_id" : min_user_id })
索引
要查找包含特定值(比如name为“Hero”的行),NotQuiteABase 必须检查表中的每一行。如果表中有很多行,这可能需要很长时间。
类似地,我们的join算法非常低效。对于左表中的每一行,它都要检查右表中的每一行是否匹配。对于两个大表来说,这可能永远都需要很长时间。
此外,您经常希望对某些列应用约束。例如,在您的users表中,您可能不希望允许两个不同的用户具有相同的user_id。
索引解决了所有这些问题。如果user_interests表上有一个关于user_id的索引,智能的join算法可以直接找到匹配项,而不必扫描整个表。如果users表上有一个关于user_id的“唯一”索引,如果尝试插入重复项,则会收到错误提示。
数据库中的每个表可以有一个或多个索引,这些索引允许您通过关键列快速查找行,在表之间有效地进行连接,并在列或列组合上强制唯一约束。
良好设计和使用索引有点像黑魔法(这取决于具体的数据库有所不同),但是如果您经常进行数据库工作,学习这些知识是值得的。
查询优化
回顾查询以查找所有对 SQL 感兴趣的用户:
SELECT users.name
FROM users
JOIN user_interests
ON users.user_id = user_interests.user_id
WHERE user_interests.interest = 'SQL'
在 NotQuiteABase 中有(至少)两种不同的方法来编写此查询。您可以在执行连接之前过滤user_interests表:
(
user_interests
.where(lambda row: row["interest"] == "SQL")
.join(users)
.select(["name"])
)
或者您可以过滤连接的结果:
(
user_interests
.join(users)
.where(lambda row: row["interest"] == "SQL")
.select(["name"])
)
无论哪种方式,最终的结果都是相同的,但是在连接之前过滤几乎肯定更有效,因为在这种情况下,join操作的行数要少得多。
在 SQL 中,您通常不必担心这个问题。您可以“声明”您想要的结果,然后由查询引擎来执行它们(并有效地使用索引)。
NoSQL
数据库的一个最新趋势是向非关系型的“NoSQL”数据库发展,它们不以表格形式表示数据。例如,MongoDB 是一种流行的无模式数据库,其元素是任意复杂的 JSON 文档,而不是行。
有列数据库,它们将数据存储在列中而不是行中(当数据具有许多列但查询只需少数列时很好),键/值存储优化了通过键检索单个(复杂)值的数据库,用于存储和遍历图形的数据库,优化用于跨多个数据中心运行的数据库,专为内存运行而设计的数据库,用于存储时间序列数据的数据库等等。
明天的热门可能甚至现在都不存在,所以我不能做更多的事情,只能告诉您 NoSQL 是一种事物。所以现在您知道了。它是一种事物。
进一步探索
-
如果你想要下载一个关系型数据库来玩玩,SQLite 快速且小巧,而 MySQL 和 PostgreSQL 则更大且功能丰富。所有这些都是免费的,并且有大量文档支持。
-
如果你想探索 NoSQL,MongoDB 非常简单入门,这既是一种福音也有点儿“诅咒”。它的文档也相当不错。
-
NoSQL 的维基百科文章几乎可以肯定地包含了在这本书写作时甚至都不存在的数据库链接。
第二十五章:MapReduce
未来已经到来,只是尚未均匀分布。
威廉·吉布森
MapReduce 是一种在大型数据集上执行并行处理的编程模型。虽然它是一种强大的技术,但其基础相对简单。
想象我们有一系列希望以某种方式处理的项目。例如,这些项目可能是网站日志、各种书籍的文本、图像文件或其他任何内容。MapReduce 算法的基本版本包括以下步骤:
-
使用
mapper函数将每个项转换为零个或多个键/值对。(通常称为map函数,但 Python 已经有一个名为map的函数,我们不需要混淆这两者。) -
收集所有具有相同键的对。
-
对每个分组值集合使用
reducer函数,以生成相应键的输出值。
注意
MapReduce 已经有些过时了,以至于我考虑从第二版中删除这一章节。但我决定它仍然是一个有趣的主题,所以最终我还是留了下来(显然)。
这些都有点抽象,让我们看一个具体的例子。数据科学中几乎没有绝对规则,但其中一个规则是,您的第一个 MapReduce 示例必须涉及单词计数。
示例:单词计数
DataSciencester 已经发展到数百万用户!这对于您的工作安全来说是好事,但也使得例行分析略微更加困难。
例如,您的内容副总裁想知道人们在其状态更新中谈论的内容。作为第一次尝试,您决定计算出现的单词,以便可以准备一份最频繁出现单词的报告。
当您只有几百个用户时,这样做很简单:
from typing import List
from collections import Counter
def tokenize(document: str) -> List[str]:
"""Just split on whitespace"""
return document.split()
def word_count_old(documents: List[str]):
"""Word count not using MapReduce"""
return Counter(word
for document in documents
for word in tokenize(document))
当您有数百万用户时,documents(状态更新)的集合突然变得太大,无法放入您的计算机中。如果您能将其适应 MapReduce 模型,您可以使用工程师们实施的一些“大数据”基础设施。
首先,我们需要一个将文档转换为键/值对序列的函数。我们希望输出按单词分组,这意味着键应该是单词。对于每个单词,我们只需发出值1来表示该对应单词的出现次数为一次:
from typing import Iterator, Tuple
def wc_mapper(document: str) -> Iterator[Tuple[str, int]]:
"""For each word in the document, emit (word, 1)"""
for word in tokenize(document):
yield (word, 1)
暂时跳过“管道”步骤 2,想象一下对于某个单词,我们已经收集到了我们发出的相应计数列表。为了生成该单词的总计数,我们只需:
from typing import Iterable
def wc_reducer(word: str,
counts: Iterable[int]) -> Iterator[Tuple[str, int]]:
"""Sum up the counts for a word"""
yield (word, sum(counts))
回到步骤 2,现在我们需要收集wc_mapper的结果并将其提供给wc_reducer。让我们考虑如何在一台计算机上完成这项工作:
from collections import defaultdict
def word_count(documents: List[str]) -> List[Tuple[str, int]]:
"""Count the words in the input documents using MapReduce"""
collector = defaultdict(list) # To store grouped values
for document in documents:
for word, count in wc_mapper(document):
collector[word].append(count)
return [output
for word, counts in collector.items()
for output in wc_reducer(word, counts)]
假设我们有三个文档["data science", "big data", "science fiction"]。
然后,将wc_mapper应用于第一个文档,产生两对("data", 1)和("science", 1)。在我们处理完所有三个文档之后,collector包含:
{"data" : [1, 1],
"science" : [1, 1],
"big" : [1],
"fiction" : [1]}
然后,wc_reducer生成每个单词的计数:
[("data", 2), ("science", 2), ("big", 1), ("fiction", 1)]
为什么要使用 MapReduce?
正如前面提到的,MapReduce 的主要优势在于它允许我们通过将处理移到数据上来分布计算。假设我们想要跨数十亿文档进行单词计数。
我们最初的(非 MapReduce)方法要求处理文档的机器能够访问每个文档。这意味着所有文档都需要在该机器上存储,或者在处理过程中将其传输到该机器上。更重要的是,这意味着机器一次只能处理一个文档。
注意
如果具有多个核心并且代码已重写以利用它们,可能可以同时处理几个。但即便如此,所有文档仍然必须到达该机器。
现在假设我们的数十亿文档分散在 100 台机器上。有了正确的基础设施(并且忽略某些细节),我们可以执行以下操作:
-
让每台机器在其文档上运行映射器,生成大量的键/值对。
-
将这些键/值对分发到多个“减少”机器,确保与任何给定键对应的所有对最终都在同一台机器上。
-
让每个减少机器按键分组然后对每组值运行减少器。
-
返回每个(键,输出)对。
这其中令人惊奇的是它的水平扩展能力。如果我们将机器数量翻倍,那么(忽略运行 MapReduce 系统的某些固定成本),我们的计算速度应该大约加快一倍。每台映射器机器只需完成一半的工作量,并且(假设有足够多的不同键来进一步分发减少器的工作)减少器机器也是如此。
更普遍的 MapReduce
如果你仔细想一想,你会发现前面示例中所有与计数特定单词有关的代码都包含在wc_mapper和wc_reducer函数中。这意味着只需做出一些更改,我们就可以得到一个更通用的框架(仍然在单台机器上运行)。
我们可以使用通用类型完全类型注释我们的map_reduce函数,但这在教学上可能会有些混乱,因此在本章中,我们对类型注释要更加随意:
from typing import Callable, Iterable, Any, Tuple
# A key/value pair is just a 2-tuple
KV = Tuple[Any, Any]
# A Mapper is a function that returns an Iterable of key/value pairs
Mapper = Callable[..., Iterable[KV]]
# A Reducer is a function that takes a key and an iterable of values
# and returns a key/value pair
Reducer = Callable[[Any, Iterable], KV]
现在我们可以编写一个通用的map_reduce函数:
def map_reduce(inputs: Iterable,
mapper: Mapper,
reducer: Reducer) -> List[KV]:
"""Run MapReduce on the inputs using mapper and reducer"""
collector = defaultdict(list)
for input in inputs:
for key, value in mapper(input):
collector[key].append(value)
return [output
for key, values in collector.items()
for output in reducer(key, values)]
然后,我们可以简单地通过以下方式计算单词数:
word_counts = map_reduce(documents, wc_mapper, wc_reducer)
这使我们能够灵活地解决各种问题。
在继续之前,请注意wc_reducer仅仅是对每个键对应的值求和。这种聚合是如此普遍,以至于值得将其抽象出来:
def values_reducer(values_fn: Callable) -> Reducer:
"""Return a reducer that just applies values_fn to its values"""
def reduce(key, values: Iterable) -> KV:
return (key, values_fn(values))
return reduce
然后我们可以轻松创建:
sum_reducer = values_reducer(sum)
max_reducer = values_reducer(max)
min_reducer = values_reducer(min)
count_distinct_reducer = values_reducer(lambda values: len(set(values)))
assert sum_reducer("key", [1, 2, 3, 3]) == ("key", 9)
assert min_reducer("key", [1, 2, 3, 3]) == ("key", 1)
assert max_reducer("key", [1, 2, 3, 3]) == ("key", 3)
assert count_distinct_reducer("key", [1, 2, 3, 3]) == ("key", 3)
等等。
示例:分析状态更新
内容 VP 对单词计数印象深刻,并询问您可以从人们的状态更新中学到什么其他内容。您设法提取出一个看起来像这样的状态更新数据集:
status_updates = [
{"id": 2,
"username" : "joelgrus",
"text" : "Should I write a second edition of my data science book?",
"created_at" : datetime.datetime(2018, 2, 21, 11, 47, 0),
"liked_by" : ["data_guy", "data_gal", "mike"] },
# ...
]
假设我们需要弄清楚人们在一周中哪一天最常谈论数据科学。为了找到这一点,我们只需计算每周的数据科学更新次数。这意味着我们需要按周几分组,这就是我们的关键。如果我们对每个包含“数据科学”的更新发出值 1,我们可以简单地通过 sum 得到总数:
def data_science_day_mapper(status_update: dict) -> Iterable:
"""Yields (day_of_week, 1) if status_update contains "data science" """
if "data science" in status_update["text"].lower():
day_of_week = status_update["created_at"].weekday()
yield (day_of_week, 1)
data_science_days = map_reduce(status_updates,
data_science_day_mapper,
sum_reducer)
作为一个稍微复杂的例子,想象一下我们需要找出每个用户在其状态更新中最常用的单词。对于 mapper,有三种可能的方法会脱颖而出:
-
将用户名放入键中;将单词和计数放入值中。
-
将单词放入键中;将用户名和计数放入值中。
-
将用户名和单词放入键中;将计数放入值中。
如果你再仔细考虑一下,我们肯定想要按 username 进行分组,因为我们希望单独考虑每个人的话语。而且我们不想按 word 进行分组,因为我们的减少器需要查看每个人的所有单词以找出哪个最受欢迎。这意味着第一个选项是正确的选择:
def words_per_user_mapper(status_update: dict):
user = status_update["username"]
for word in tokenize(status_update["text"]):
yield (user, (word, 1))
def most_popular_word_reducer(user: str,
words_and_counts: Iterable[KV]):
"""
Given a sequence of (word, count) pairs,
return the word with the highest total count
"""
word_counts = Counter()
for word, count in words_and_counts:
word_counts[word] += count
word, count = word_counts.most_common(1)[0]
yield (user, (word, count))
user_words = map_reduce(status_updates,
words_per_user_mapper,
most_popular_word_reducer)
或者我们可以找出每个用户的不同状态点赞者数量:
def liker_mapper(status_update: dict):
user = status_update["username"]
for liker in status_update["liked_by"]:
yield (user, liker)
distinct_likers_per_user = map_reduce(status_updates,
liker_mapper,
count_distinct_reducer)
示例:矩阵乘法
从 “矩阵乘法” 回想一下,给定一个 [n, m] 矩阵 A 和一个 [m, k] 矩阵 B,我们可以将它们相乘得到一个 [n, k] 矩阵 C,其中 C 中第 i 行第 j 列的元素由以下给出:
C[i][j] = sum(A[i][x] * B[x][j] for x in range(m))
只要我们像我们一直在做的那样,将我们的矩阵表示为列表的列表,这就起作用了。
但是大矩阵有时是 sparse 的,这意味着它们的大多数元素等于 0。对于大稀疏矩阵,列表列表可能是非常浪费的表示方式。更紧凑的表示法仅存储具有非零值的位置:
from typing import NamedTuple
class Entry(NamedTuple):
name: str
i: int
j: int
value: float
例如,一个 10 亿 × 10 亿的矩阵有 1 quintillion 个条目,这在计算机上存储起来并不容易。但如果每行只有几个非零条目,这种替代表示法则小得多。
在这种表示法给定的情况下,我们发现可以使用 MapReduce 以分布方式执行矩阵乘法。
为了激励我们的算法,请注意每个元素 A[i][j] 仅用于计算 C 的第 i 行的元素,每个元素 B[i][j] 仅用于计算 C 的第 j 列的元素。我们的目标是使我们的 reducer 的每个输出成为 C 的一个单一条目,这意味着我们的 mapper 需要发出标识 C 的单个条目的键。这建议以下操作:
def matrix_multiply_mapper(num_rows_a: int, num_cols_b: int) -> Mapper:
# C[x][y] = A[x][0] * B[0][y] + ... + A[x][m] * B[m][y]
#
# so an element A[i][j] goes into every C[i][y] with coef B[j][y]
# and an element B[i][j] goes into every C[x][j] with coef A[x][i]
def mapper(entry: Entry):
if entry.name == "A":
for y in range(num_cols_b):
key = (entry.i, y) # which element of C
value = (entry.j, entry.value) # which entry in the sum
yield (key, value)
else:
for x in range(num_rows_a):
key = (x, entry.j) # which element of C
value = (entry.i, entry.value) # which entry in the sum
yield (key, value)
return mapper
然后:
def matrix_multiply_reducer(key: Tuple[int, int],
indexed_values: Iterable[Tuple[int, int]]):
results_by_index = defaultdict(list)
for index, value in indexed_values:
results_by_index[index].append(value)
# Multiply the values for positions with two values
# (one from A, and one from B) and sum them up.
sumproduct = sum(values[0] * values[1]
for values in results_by_index.values()
if len(values) == 2)
if sumproduct != 0.0:
yield (key, sumproduct)
例如,如果你有这两个矩阵:
A = [[3, 2, 0],
[0, 0, 0]]
B = [[4, -1, 0],
[10, 0, 0],
[0, 0, 0]]
你可以将它们重写为元组:
entries = [Entry("A", 0, 0, 3), Entry("A", 0, 1, 2), Entry("B", 0, 0, 4),
Entry("B", 0, 1, -1), Entry("B", 1, 0, 10)]
mapper = matrix_multiply_mapper(num_rows_a=2, num_cols_b=3)
reducer = matrix_multiply_reducer
# Product should be [[32, -3, 0], [0, 0, 0]].
# So it should have two entries.
assert (set(map_reduce(entries, mapper, reducer)) ==
{((0, 1), -3), ((0, 0), 32)})
在如此小的矩阵上这并不是非常有趣,但是如果你有数百万行和数百万列,它可能会帮助你很多。
一则:组合器
你可能已经注意到,我们的许多 mapper 看起来包含了大量额外的信息。例如,在计算单词数量时,我们可以发射 (word, None) 并且只计算长度,而不是发射 (word, 1) 并对值求和。
我们没有这样做的一个原因是,在分布式设置中,有时我们想要使用组合器来减少必须从一台机器传输到另一台机器的数据量。如果我们的某个 mapper 机器看到单词 data 出现了 500 次,我们可以告诉它将 ("data", 1) 的 500 个实例合并成一个 ("data", 500),然后再交给 reducer 处理。这样可以减少传输的数据量,从而使我们的算法速度显著提高。
由于我们写的 reducer 的方式,它将正确处理这些合并的数据。(如果我们使用 len 写的话,就不会。)
深入探讨
-
正如我所说的,与我写第一版时相比,MapReduce 现在似乎不那么流行了。也许不值得投入大量时间。
-
尽管如此,最广泛使用的 MapReduce 系统是Hadoop。有各种商业和非商业的发行版,以及一个庞大的与 Hadoop 相关的工具生态系统。
-
Amazon.com 提供了一个Elastic MapReduce服务,可能比自己搭建集群更容易。
-
Hadoop 作业通常具有较高的延迟,这使得它们在“实时”分析方面表现不佳。这些工作负载的流行选择是Spark,它可以像 MapReduce 一样运行。