Имплементация HyperLogLog на C#
Дата: October 26, 2014
Метки: cardinality-estimation, big-data, HyperLogLog, C#, FNV, MurmurHash3
Описание реализованных методов
Исходя из описания алгоритма HyperLogLog его имплементация не должна представлять особых проблем. Ниже представлено переложение алгоритма HyperLogLog на C#.
- Конструктор
public HyperLogLog(int k)
принимает параметр k=4..16 такое, что \( m={ 2 }^{ k } \) - число байтовых регистров, которое будет выделено в оперативной памяти для приблизительного подсчета числа уникальных элементов лежащего в интервале \( \left[ 0,10^9 \right] \). При этом стандартная ошибка при оценке числа уникальных элементов будет составлять \( \frac { 1.04 }{ \sqrt { { 2 }^{ k } } } \). - Метод
public void Add(int hash)
принимает 32-битный хеш элемента из набора, в котором нужно оценить число уникальных элементов. В главе о хешировании можно прочесть слова Дональда Кнута, который говорит, что на практике возможно создать хеш-функцию, которая бы из неслучайных данных создавала данные хоть и не совсем случайные, но очень похожие на случайные. Этому стоит уделить особое внимание, потому что недостаточно хорошая хеш-функция, которая будет генерировать хеши не похожие на случайные, сведет весь алгоритм на нет. В реализации метода можно можно увидеть, что после получения индекса регистра из хеша (по первым k битам) далее ведется подсчет младших нулевых битов, а не старших, как это описано в алгоритме. На самом деле совершенно нет разницы, считаем мы последовательность нулевых битов в старших битах или в младших, ведь хеш - это как бы случайное число. - Метод
public double EstimateCount()
позволяет произвести оценку числа уникальных элементов, хеши которых были ранее добавлены через вызов методаpublic void Add(int hash)
. - Наконец метод
public void UnionWith(HyperLogLog other)
позволяет объединить уникальные элементы из разных наборов. Это может быть полезно, если, например, исходная выборка была разбита на несколько частей и для каждой части независимо производилась оценка числа уникальных элементов. Очень важно помнить, что объединяемые объекты классаHyperLogLog
должны использовать то же самое число регистров (параметр k должен быть одним и тем же) и для добавляемых элементов должна использоваться одна и та же хеш-функция. Нельзя использовать для двух разных объектов классаHyperLogLog
две разные пусть и очень хорошие хеш-функции, т.к. в результате в лучшем случае получится сумма уникальных элементов из каждого набора по отдельности, в худшем случае просто чепуха.
Проверяем правильность работы алгоритма на случайных данных
Прежде чем пробовать алгоритм на каких-то реальных данных, проверим, насколько хорошо он работает на случайных. То есть вместо хешей будем подавать случайные хеши (предполагая, что коллизий будет мало) и проверять на сколько алгоритм ошибается в оценке.
Для проверки возьмем криптографический генератор случайных чисел, не будем полагаться на простой random. Напишем такой генератор бесконечного списка случайных 32-битных чисел:
IEnumerable<int> GetRandomInts()
{
var rng = new System.Security.Cryptography.RNGCryptoServiceProvider();
var intBytes = new byte[4];
while(true)
{
rng.GetBytes(intBytes);
var rndInt = BitConverter.ToInt32(intBytes, 0);
yield return rndInt;
}
}
Прогоним теперь через объект класса HyperLogLog
наши случайные числа и в определенных точках сверим оценку с числом поданых случайных чисел:
// здесь будут точки, в которых будем производить оценку
var ticks = new HashSet<int>(Enumerable
.Range(1, 8)
.Select(i => (int)Math.Pow(10,i))
.SelectMany(i => new[]{i, 5*i}));
var hll = new HyperLogLog(10); // k = 10
var cnt = 0; // число добавленных хешей
Console.WriteLine("| Разных значений | Оценка | Ошибка,% |");
Console.WriteLine("|----------------:|-----------:|:--------:|");
foreach (var hash in GetRandomInts().Take(100000000))
{
hll.Add(hash); // добавляем хеш
cnt++; // увеличиваем число добавленных хешей
if (!ticks.Contains(cnt)) { continue; }
// производим оценку и вычисляем ошибку
var estimateCount = hll.EstimateCount();
Console.WriteLine("|{0,17}|{1,12:0.00}|{2,10:0.0000}|", cnt, estimateCount, Math.Abs(cnt - estimateCount)/cnt*100.0);
}
При k=10 получается такая таблица (стандартная ошибка \( \frac { 1.04 }{ \sqrt { { 2 }^{ 10 } } } = 3.25\)%):
Разных значений | Оценка | Ошибка,% |
---|---|---|
10 | 10,05 | 0,4915 |
50 | 51,26 | 2,5239 |
100 | 104,12 | 4,1183 |
500 | 502,30 | 0,4596 |
1000 | 993,76 | 0,6242 |
5000 | 4934,58 | 1,3083 |
10000 | 9654,82 | 3,4518 |
50000 | 50896,97 | 1,7939 |
100000 | 101884,92 | 1,8849 |
500000 | 509183,34 | 1,8367 |
1000000 | 1023285,78 | 2,3286 |
5000000 | 4934955,44 | 1,3009 |
10000000 | 10073259,12 | 0,7326 |
50000000 | 49928262,24 | 0,1435 |
100000000 | 97500823,59 | 2,4992 |
При k=16 получается такая таблица (стандартная ошибка \( \frac { 1.04 }{ \sqrt { { 2 }^{ 16 } } } = 0.40625\)%):
Разных значений | Оценка | Ошибка,% |
---|---|---|
10 | 10,00 | 0,0076 |
50 | 50,02 | 0,0382 |
100 | 100,08 | 0,0764 |
500 | 501,92 | 0,3834 |
1000 | 1002,63 | 0,2631 |
5000 | 5010,77 | 0,2153 |
10000 | 10027,46 | 0,2746 |
50000 | 50124,56 | 0,2491 |
100000 | 99690,22 | 0,3098 |
500000 | 502979,77 | 0,5960 |
1000000 | 997461,99 | 0,2538 |
5000000 | 4984715,12 | 0,3057 |
10000000 | 9982520,07 | 0,1748 |
50000000 | 50020851,75 | 0,0417 |
100000000 | 100022493,99 | 0,0225 |
Как видим ошибки оценок довольно неплохо укладываются в теорию. Теперь можно взять какой-нибудь набор данных и выбрать хеш-функцию для него.
Выбираем функцию хеширования
Как выше было сказано в описании, выбору хеш-функции следует уделить особое внимание по нескольким причинам:
- Если хеш-функция выдает не достаточно случайные хеши для начальных данных, вычисленная оценка будет скорее всего очень сильно занижать реальное значение так, что погрешность может достигать почти 100% делая вычисления бессмысленными.
- Если статистика собирается на длительном промежутке времени, нельзя будет на ходу просто поменять хеш-функцию на другую более хорошую, т.к. иначе уже собранную статистику придется сбросить в ноль.
Для начала опробуем хеш-функцию FNV-1a на 32-битных целых числах.
static int Fnv1AHash(byte[] bytes)
{
unchecked
{
const int fnv32Offset = (int)2166136261;
const int fnv32Prime = 16777619;
int hash = fnv32Offset;
foreach (var t in bytes)
{
hash = hash ^ t;
hash *= fnv32Prime;
}
return hash;
}
}
Тепер для чисел целых 32-битных чисел начиная с 0 применим эту хеш-функцию. В коде, который мы применяли для тестирования алгоритма на случайных числах заменим строку:
foreach (var hash in GetRandomInts().Take(100000000))
на
foreach (var hash in Enumerable
.Range(0, 100000000) // все целые числа, начиная с нуля в количестве 100000000
.Select(BitConverter.GetBytes) // конвертируем в массив из 4-х байтов
.Select(Fnv1AHash)) // применим к каждому массиву хеш-функцию
При k=10 получим такие результаты:
Разных значений | Оценка | Ошибка,% |
---|---|---|
10 | 10,05 | 0,4915 |
50 | 51,26 | 2,5239 |
100 | 105,23 | 5,2260 |
500 | 549,08 | 9,8159 |
1000 | 1056,33 | 5,6332 |
5000 | 6634,90 | 32,6980 |
10000 | 11027,98 | 10,2798 |
50000 | 41923,40 | 16,1532 |
100000 | 54234,00 | 45,7660 |
500000 | 126984,56 | 74,6031 |
1000000 | 353391,49 | 64,6609 |
5000000 | 6233096,62 | 24,6619 |
10000000 | 13254027,76 | 32,5403 |
50000000 | 51887411,47 | 3,7748 |
100000000 | 81864421,81 | 18,1356 |
Как видим, хеш-функция для наших входных данных (последовательности целых чисел начиная с нуля) не достаточно хороша, т.к. ошибка очень большая получается. Что можно сделать? Можно попробовать сделать два раунда хеширования - сначала хешируем входные данные, затем хешируем полученный хеш:
foreach (var hash in Enumerable
.Range(0, 100000000) // все целые числа, начиная с нуля в количестве 100000000
.Select(BitConverter.GetBytes) // конвертируем в массив из 4-х байтов
.Select(Fnv1AHash) // применим к каждому массиву хеш-функцию
.Select(BitConverter.GetBytes) // конвертируем каждый хеш в массив из 4-х байтов
.Select(Fnv1AHash)) // применим к каждому массиву снова хеш-функцию
Теперь при k=10 результаты заметно улучшились:
Разных значений | Оценка | Ошибка,% |
---|---|---|
10 | 10,05 | 0,4915 |
50 | 50,21 | 0,4223 |
100 | 100,80 | 0,8026 |
500 | 486,10 | 2,7810 |
1000 | 967,70 | 3,2300 |
5000 | 4899,62 | 2,0076 |
10000 | 9788,63 | 2,1137 |
50000 | 47822,37 | 4,3553 |
100000 | 97091,62 | 2,9084 |
500000 | 474394,98 | 5,1210 |
1000000 | 974288,21 | 2,5712 |
5000000 | 5439552,49 | 8,7910 |
10000000 | 9955849,45 | 0,4415 |
50000000 | 49659117,73 | 0,6818 |
100000000 | 94593348,50 | 5,4067 |
Многие эксперименты показывают, что MurmurHash3 хоть и не является криптографически-безопасной хеш-функцией, но обладает хорошим распределением, обладает высокой скоростью и устойчивостью к коллизиям. За основу была взята эта реализация и немного допилена напильником.
После замены
foreach (var hash in Enumerable
.Range(0, 100000000) // все целые числа, начиная с нуля в количестве 100000000
.Select(BitConverter.GetBytes) // конвертируем в массив из 4-х байтов
.Select(MurMurHash3.Hash)) // применим к каждому массиву хеш-функцию
были полученные такие результаты при k=10:
Разных значений | Оценка | Ошибка,% |
---|---|---|
10 | 10,05 | 0,4915 |
50 | 51,26 | 2,5239 |
100 | 101,91 | 1,9067 |
500 | 499,04 | 0,1927 |
1000 | 1001,71 | 0,1706 |
5000 | 5101,16 | 2,0233 |
10000 | 9625,14 | 3,7486 |
50000 | 51753,74 | 3,5075 |
100000 | 98546,11 | 1,4539 |
500000 | 487898,62 | 2,4203 |
1000000 | 984749,69 | 1,5250 |
5000000 | 5078692,01 | 1,5738 |
10000000 | 10075100,60 | 0,7510 |
50000000 | 51533442,86 | 3,0669 |
100000000 | 101786556,87 | 1,7866 |
При k=16:
Разных значений | Оценка | Ошибка,% |
---|---|---|
10 | 10,00 | 0,0076 |
50 | 50,02 | 0,0382 |
100 | 100,08 | 0,0764 |
500 | 497,89 | 0,4227 |
1000 | 998,57 | 0,1431 |
5000 | 4979,47 | 0,4106 |
10000 | 9961,07 | 0,3893 |
50000 | 50000,06 | 0,0001 |
100000 | 99580,45 | 0,4196 |
500000 | 497924,41 | 0,4151 |
1000000 | 996976,43 | 0,3024 |
5000000 | 5003075,05 | 0,0615 |
10000000 | 9991536,18 | 0,0846 |
50000000 | 50289746,12 | 0,5795 |
100000000 | 100782312,74 | 0,7823 |
Таким образом MurmurHash3 оказалась действительно неплохой хеш-функцией в нашем случае.
Объединяем результаты
Теперь наглядно убедимся, что можно объединять результаты оценки уникальных элементов. Для этого возьмем числа из интервала [0, 9999] произведем оценку числа уникальных элементов на этом интервале, затем то же самое отдельно сделаем для интервала [5000, 14999], после чего объединим результаты. Т.е. в итоге у нас должно получиться порядка 15000 уникальных элементов:
var hll1 = new HyperLogLog(10);
var hll2 = new HyperLogLog(10);
foreach (var hash in Enumerable
.Range(0, 10000) // все целые числа, начиная с нуля в количестве 10000
.Select(BitConverter.GetBytes) // конвертируем в массив из 4-х байтов
.Select(MurMurHash3.Hash)) // применим к каждому массиву хеш-функцию
{
hll1.Add(hash);
}
foreach (var hash in Enumerable
.Range(5000, 10000) // все целые числа, начиная с 5000 в количестве 10000
.Select(BitConverter.GetBytes) // конвертируем в массив из 4-х байтов
.Select(MurMurHash3.Hash)) // применим к каждому массиву хеш-функцию
{
hll2.Add(hash);
}
Console.WriteLine("Уникальных элементов на интервале 0-9999: {0:0.00}", hll1.EstimateCount());
Console.WriteLine("Уникальных элементов на интервале 5000-14999: {0:0.00}", hll2.EstimateCount());
hll1.UnionWith(hll2); // в hll1 будет объединение всех уникальных элементов
Console.WriteLine("Уникальных элементов на интервале 0-14999: {0:0.00}", hll1.EstimateCount());
В результате получаем:
Уникальных элементов на интервале 0-9999: 9625,14
Уникальных элементов на интервале 5000-14999: 10013,74
Уникальных элементов на интервале 0-14999: 15139,33
Что нисколько не отличается, если бы мы произвели измерения на одном объекте:
var hll = new HyperLogLog(10);
foreach (var hash in Enumerable
.Range(0, 15000) // все целые числа, начиная с нуля в количестве 15000
.Select(BitConverter.GetBytes) // конвертируем в массив из 4-х байтов
.Select(MurMurHash3.Hash)) // применим к каждому массиву хеш-функцию
{
hll.Add(hash);
}
Console.WriteLine("Уникальных элементов на интервале 0-14999: {0:0.00}", hll.EstimateCount());
И в этом случае получили абсолютно тот же самый результат:
Уникальных элементов на интервале 0-14999: 15139,33
Это не должно нас удивлять, т.к. хеши одинаковых элементов совпадают до бита, и попадают в одни и те же регистры, а максимум последовательности нулей в хешах можно искать в произвольном порядке и в результате получим одно и то же число. Это все равно что искать максимальное число в массиве - можно поделить его на несколько частей, параллельно там найти максимумы, а потом среди максимумов найти один единственный максимум. То же самое происходит в нашем случае на уровне отдельных регистров.
Имплементация хеш-функции MurmurHash3 на C#
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
|
Имплементация алгоритма HyperLogLog на C#
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
|