机器学习背景下的哈希算法
Posted 论智
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了机器学习背景下的哈希算法相关的知识,希望对你有一定的参考价值。
编者按:Tyler Elliot Bettilyon深入浅出地介绍了哈希算法以及基于机器学习的索引结构。
图片来源:Tobias Fischer(Unsplash)
2017年12月,Google和MIT的研究人员发表了一篇刺激的论文,关于“learned index structures”(学习索引结构)。作者们在摘要中如此写道:
我们相信将数据管理系统的核心组件替换为学习模型的想法,对未来的系统设计有着深远的影响,这篇论文仅仅提供了对未来的可能性的短暂一瞥。
B树(B-Tree)和哈希表(Hash Map)是索引算法的基石,而Google和MIT的研究人员可能发现了新的竞争方案。这一发现可谓是“一石惊起千重浪”。
新研究提供了一个重新检视某个领域的基础的绝佳机会;而像索引这样基础(同时研究充分)的主题可不是经常有机会出现突破的。本文的主旨是介绍哈希表,简要地总结影响其性能的因素,并直观地介绍上述论文中应用于索引的机器学习概念。
(如果你很熟悉哈希表、碰撞处理策略、哈希函数性能,你可以跳读本文,或者直接跳过本文,阅读底部链接的三篇文章,以深入这些主题。)
作为对Google/MIT的回应,Peter Bailis及其斯坦福研究团队警告我们不要扔掉算法教材。Peter Bailis及其团队在不使用任何机器学习的前提下,得到了Google/MIT论文中相似的结果(他们使用的是经典的Cuckoo哈希)。
无独有偶,Thomas Neumann描述了基于B树和样条插值取得相似结果的方法。
当然,其实Google/MIT的论文中已经写了:
值得重视的是,我们并不主张用学习索引结构完全取代传统的索引结果。相反,我们只是概述了一种创建索引的新方法,该方法可以作为现有工作的补充,并且,可能为一个数十年的旧领域开启了一个全新的研究方向。
所以,这些人在争论什么?哈希表和B树是不是已经被钦定为衰老的名人堂成员?机器将重写算法教科书吗?如果机器学习策略真比我们熟知的索引算法更好,那将意味着什么?在什么情况下,学习索引优于旧算法?
要回答这些问题,我们需要了解什么是索引,索引解决什么问题,什么因素使一种索引方案比另一种更合适。
什么是索引?
就核心而言,索引让事物更容易找到和获取。早在计算机发明之前,人类已经在创建索引了。文件柜、百科全书、杂货店的商品标签,都是索引。
亚历山大图书馆的第一任馆长泽诺多托斯,设计了一个索引系统,将藏书按体裁分置于不同房间,同一体裁的书籍上架时按字母顺序排列。他的同事卡利马科斯进一步为图书馆编制了一份目录(《Pinakes》),使馆员可以通过作者姓名查找其著作在图书馆中的位置。(更多详情,请阅读The Great Library of Alexandria?一文。)自此以后,图书馆索引有不少创新,包括1876年发明的杜威十进制图书分类法。
在亚历山大图书馆,索引将一段信息(书名或作者)映射到图书馆中的物理位置。尽管我们的计算机是数字设备,计算机中的信息实际上存储于物理位置。文章的文本也好,信用卡的交易信息也罢,甚至是受到惊吓的喵星人的视频,数据存在于计算机的某个或某些物理位置。
数据库是典型的索引用例。我们设计数据库,以储存大量信息,一般而言,我们希望能高效地从数据库获取信息。搜索引擎的核心是互联网信息的巨型索引。哈希表(hash table)、二叉搜索树(binary search tree)、前缀树(trie)、B树(B-Tree)、布隆过滤器(bloom filter)都是索引。
不难想见,在巨大的亚历山大图书馆错综复杂的大厅里寻找特定的东西很有挑战性,但我们不应该想当然地以为人类产生的数据以指数级增长。互联网上的信息远远超过了任何时代的图书馆,而Google的目标是索引全部这些信息。人类已经创建了许多索引的策略,这里我们将查看一个一直以来都很常用的数据结构,它碰巧是一个索引结构:哈希表。
什么是哈希表?
初看起来,哈希表是一个基于哈希函数的简单数据结构。有各种不同的哈希函数,本文中的哈希函数限于哈希表中用的哈希函数,不包括密码学上的哈希函数,校验值(checksum),及其他种类的哈希函数。
哈希函数接受某个输入值(例如一个数字或一段文本),然后返回一个整数,这个整数称为哈希码(hash code)或哈希值(hash value)。对任何给定输入而言,哈希码总是相同的;也就是说,哈希函数是确定的。
创建哈希表时,我们首先分配一些空间(在内存中或在存储器中)——你可以将其想象为新创建一个尺寸随意的数组。如果有很多数据,我们可能会使用一个较大的数组;如果数据不多,我们可能会使用一个较小的数组。当我们想要索引一段数据时,我们创建一个键值对,其中键为辨别数据的信息(比如数据库记录的主键),值为数据本身(比如一整条数据库记录)。
我们将键传给哈希函数,以便将值插入哈希表。哈希函数返回一个整数(哈希码),我们使用这个整数——以数组大小为模——作为数组的存储索引。如果我们想从哈希表中取回值,我们直接根据键重新计算哈希码,然后根据所得位置从数组中取得数据。
在使用杜威十进制系统的图书馆中,“键”是图书所属的分类,“值”是图书本身。“哈希码”是使用杜威十进制过程创建的数值。例如,一本解析几何的书的“哈希码”是516.3. 自然科学是500,数学是510,几何是516,解析几何是516.3. 从这一角度来说,杜威十进制系统可以被看成图书的哈希函数,图书根据其哈希值摆放。
这不是一个完美的类比。和杜威十进制数字不同,哈希表中用于索引的哈希值通常不包含什么信息——在一个完美的类比下,图书馆的书目将包含每本书的精确位置,基于有关书的一段信息编排(也许是书名,也许是作者的姓氏,也许是ISBN号……),但图书不会按照任何有意义的方式排列,除了同键的图书会放在同一个书架上,你可以通过图书的键在图书馆的书目系统中查找书架号。
这一简单过程是所有哈希表的基础。然而,基于这一简单想法的技术,产生了很多复杂性,这是为了确保哈希索引的正确和高效。
哈希索引的性能考量
哈希表的复杂度和优化主要源于哈希碰撞。当两个以上键产生同一哈希码时,碰撞就发生了。考虑以下简单哈希函数(假定键为整数):
function hashFunction(key) {
return (key * 13) % sizeOfArray;
}
尽管任何独特的整数乘以13后都将产生独特的整数,根据鸽巢原理,所得哈希码终将出现重复:我们无法把6件物品放到5个筐里,同时保证不把两件物品放到一个筐里。因为我们的存储空间是有限的,我们不得不使用以数组大小为模的哈希值,因此我们总会遇到碰撞。
稍后我们将讨论处理这些无法避免的碰撞的策略,但首先值得一提的是,哈希函数的选择将提高或减少碰撞的几率。想象一下我们有16个存储位置,同时我们将从以下两个哈希函数中选一个:
function hash_a(key) {
return (13 * key) % 16;
}
function hash_b(key){
return (4 * key) % 16;
}
在这一情形下,如果我们哈希0-32,hash_b
将产生28次碰撞,哈希值0、4、8、12将各有7次碰撞。而hash_a
的碰撞将均匀散布在每个索引上,共有16次碰撞。这是因为hash_b
的乘数4是哈希表尺寸16的因子。而hash_a
选用的是一个质数,所以除非哈希表的尺寸是13的倍数,我们不会碰到hash_b
的分组问题。
我们可以写一段代码验证这一点:
function hash_a(key) {
return (13 * key) % 16;
}
function hash_b(key){
return (4 * key) % 16;
}
let table_a = Array(16).fill(0);
let table_b = Array(16).fill(0);
for(let i = 0; i < 32; i++) {
let hash_code_a = hash_a(i);
let hash_code_b = hash_b(i);
table_a[hash_code_a] += 1;
table_b[hash_code_b] += 1;
}
console.log(table_a); // [2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2]
console.log(table_b); // [8,0,0,0,8,0,0,0,8,0,0,0,8,0,0,0]
这种选用质数作乘数的策略实际上非常常见。质数降低了哈希码和数组尺寸共有同一因子的可能性,从而降低了碰撞的机会。
multiply-shift哈希是一个类似的策略,但用快速的移位运算代替了模运算。Murmur哈希和Tabulation哈希与multiply-shift哈希类似,分别用循环移位运算及异或运算(结合查表)代替了模运算。评测这些哈希函数涉及比较运算速度、产生的哈希码分布、处理不同种类数据(例如,整数、浮点数、字符串)的灵活性。SMhasher提供了哈希函数的评测套件。
选择良好的哈希函数可以降低碰撞率,加快计算速度。不幸的是,无论选择哪个哈希函数,我们终将遇到碰撞。如何处理碰撞对哈希表的总体性能将有显著影响。处理碰撞的两个常用策略是链表法(chaining)和线性探测法(linear probing)。
链表法直截了当,也容易实现。我们在哈希表的索引中存储一个链表的头指针。如果碰到碰撞,我们就将发生碰撞的值附加到链表末尾。因此,严格来说,查找哈希表不再是“常数时间”,因为我们需要遍历一个链表。如果哈希函数产生许多碰撞,会形成非常长的链表,导致性能下降。
线性探测这个概念也很简单,不过实现起来有点麻烦。在线性探测中,哈希表的每个索引仍然对应单个元素。如果索引i遇到碰撞,我们将检查索引i+1是否为空,如果i+1为空,那么我们就将数据储存在那里;如果i+1不为空,那么我们就检查i+2,如果仍不为空,我们再检查i+3,以此类推,直到找到空位。同样,严格来说,查找哈希表不再是“常数时间”,如果一个索引有很多碰撞,我们需要搜寻一个长序列。此外,每次碰撞都将增加出现下次碰撞的概率,因为,和链表法不同,碰撞项最终占去一个新索引。
听起来链表法更好,但由于性能上的特点,线性探测法得到了广泛应用。主要而言,这是因为链表对缓存的利用很糟糕,而数组能很好地利用缓存。相同尺寸的情况下,检查链表中的所有链接比检查数组中的所有索引要慢太多。这是因为数组的索引在物理上是连续的。而在链表中,创建新节点时才指定其位置,这个新节点和它的邻居在物理上不一定是连续的。结果导致链表中相邻的节点很少在物理上相邻(指RAM芯片的实际位置)。而CPU缓存的工作机制决定了,访问连续的内存位置很快,而随机访问内存位置要慢得多。当然,实际情况比上面说的要复杂一点,详见What Every Programmer Should Know About Memory一文。
机器学习基础
想要理解机器学习如何用于重建哈希表(和其他索引技术)的关键特性,让我们快速地回顾一下统计建模的主要思想。统计学中的模型是一个函数,它接受某个向量作为输入,返回一个标签(分类)或一个数值(回归)。输入向量包含数据点的所有相关信息,而标签/数值输出是模型的预测。
在一个预测高中生能不能上哈佛的模型中,向量可能包含学生的GPA、SAT分数、所属的兴趣社团,以及其他有关学术成就的值;标签将是真/假(能上哈佛/不能上哈佛)。
在预测抵押贷款违约率的模型中,输入向量可能包含信用分、信用卡账户数、超期率、年收入,以及其他抵押贷款申请人的经济状况信息;模型可能会返回0到1之间的数字,表示违约的概率。
一般而言,机器学习用于创建统计模型。机器学习从业人员将大型数据集和机器学习算法结合起来,在数据集上运行算法得到的结果称为训练好的模型(trained model)。机器学习的核心是创造自动基于原始数据创建精确模型的算法,无需人类帮助机器“理解”数据实际上表示什么。这和其他人工智能不同,在其他人工智能中,人类细致检查数据,给计算机提供一些数据意味着什么的线索(例如,通过定义启发式算法),并定义计算机如何使用数据(例如,使用极小化极大算法或A*搜索算法)。不过,在实践中,机器学习经常组合经典的非学习技术;一个AI智能体将经常同时使用学习策略和非学习策略以达成目标。
考虑下著名的国际象棋AI“深蓝”和最近的围棋AI“AlphaGo”。深蓝完全是一个非学习AI;人类计算机程序员和人类国际象棋专家合作创建了深蓝。深蓝从不“学习”任何东西——人类国际象棋专家仔细的编码了机器的评估函数。
可视化AlphaGo的树搜索算法;来源:blogs.loc.gov
和深蓝不同,AlphaGo在不借助围棋专家的明确指示的前提下创建了自己的评估函数。在这一情形下,评估函数是一个训练好的模型。通过成千上万次对局,机器学习算法决定如何评估任何具体的棋盘状态。AlphaGo通过查看数百万样本,教会自己哪一步将提供最大的获胜几率。
(以上大大简化了AlphaGo这样的系统的工作机制,请通过AlphaGo创造者DeepMind的博客了解详情。)
作为索引的模型,偏离ML常态的做法
通常而言,机器学习模型的任务是给出估计。而对索引数据而言,用估计作为结果是不可接受。索引的唯一任务是找出数据在内存中的精确位置。因此,Google记录了训练中每个节点的最大和最小误差。使用这些值作为边界,可以通过在边界内搜索找到元素的精确位置。
另一方面,机器学习通常需要小心地避开过拟合;过拟合的模型在训练数据上能给出非常精确的预测,但在训练集之外的表现非常糟糕。而在索引任务中,训练数据就是被索引的数据,因此训练数据同时也是测试数据。从这个意义上说,索引注定“过拟合”。不过,没有免费的午餐,如果我们在索引中增加新的项,模型可能会给出极其糟糕的预测。因此,这一学习模型的应用范围限制在只读数据库系统。
学习哈希
Google的研究人员感兴趣的一个问题是:知道数据的分布是否有助于创建更好的索引?在我们之前提到的传统策略(乘以质数、multiply-shift哈希、Murmur哈希……)中,数据的分布被明确地忽视了。每个待处理的项被视为独立值,而不是一个更大的数据集(具有许多有价值的属性可供考量)的一部分。结果,即使在许多最先进的哈希表中,都有很多空间被浪费了。
这没什么好惊讶的,基于输入数据学习所得的哈希函数当然可以更均匀地分布数据,因为ML模型已经知道了数据的分布!这是一个潜在的显著降低基于哈希的索引所需存储空间的强有力的方法。不过,这不是没有代价的,ML模型需要训练步骤,因此比我们之前提到的标准哈希函数要慢。
也许基于ML的哈希函数可以用于像数据仓库(data warehousing)这样高效使用内存至关重要,同时算力不是瓶颈的场景。
然而,在内存效率很关键的场景中,我们已经有Cuckoo哈希了。
Cuckoo哈希
在内存受限的场景下,这一策略非常高效。即使在高占用率下,Cuckoo哈希的性能依然稳定(链表法和线性探测法则不然)。
Bailis及其斯坦福团队发现,通过一些优化,即使在99%占用下,Cuckoo哈希仍然能够保持高性能。基本上,Cuckoo哈希能够达到“机器学习”哈希函数的高利用率,而无需昂贵的训练阶段。
索引的未来
随着更多ML工具的研发,以及TPU之类的硬件的进展,索引能从机器学习获益更多。另一方面,Cuckoo哈希这样的算法提醒我们机器学习技术不是万能神药。
索引的基础看起来不会在一夜之间被机器学习策略取代,但自我调节索引这一想法是一个强大而激动人心的概念。在未来,DynamoDB、Cassandra,乃至PostgreSQL、mysql都可能逐渐采用机器学习策略。
本文略过、简化了许多细节。有好奇心的读者可以进一步阅读以下文章:
The Case For Learned Indexes (Google/MIT)
Don’t Throw Out Your Algorithms Book Just Yet: Classical Data Structures That Can Outperform Learned Indexes (Stanford)
A Seven-Dimensional Analysis of Hashing Methods and its Implications on Query Processing (Saarland University)
以上是关于机器学习背景下的哈希算法的主要内容,如果未能解决你的问题,请参考以下文章