সরাসরি পর্বে যান
পর্ব ৪৫ · ৬৬-এর মধ্যে · মডিউল ৭

Probabilistic data structure — Bloom filter, count-min sketch আর HyperLogLog

Probabilistic Data Structures — Bloom Filters, Count-Min Sketch & HyperLogLog

Taught in Bangla, this lesson covers probabilistic data structures that trade exactness for memory: hashing with seeds and double hashing, Bloom filters with the false-positive formula, optimal k and bits per key, counting and cuckoo filters, count-min sketch error bounds with heavy hitters and top-k, and HyperLogLog registers, standard error and merging, with hands-on simulators and interview sizing exercises.

এই পর্বে যা শিখবেন

  • কোন তিন ধরনের প্রশ্নে (আছে কি না, কতবার, কতজন আলাদা) আনুমানিক কাঠামো লাগে, আর নিখুঁত উত্তরের memory-র হিসাব
  • ভালো hash-এর শর্ত, seed আর double hashing — দুটি hash থেকে k টি
  • Bloom filter-এর false-positive সূত্রের প্রমাণ, সেরা k, প্রতি key-এ bit, counting ও cuckoo filter, আর বাস্তব ব্যবহার — simulator-এ মেপে দেখা
  • Count-min sketch-এর ভুলের সীমা, heavy hitters আর heap দিয়ে top-k; HyperLogLog-এর register, standard error আর merge
  • ইন্টারভিউতে কাঠামো বাছাই আর আকার হিসাব, আর AI-এর দেওয়া sketch-নকশা যাচাই

১ · নির্ভুলতার বদলে memory — কেন আনুমানিক উত্তর

বইমেলার একটি গেটের কথা ভাবুন। আয়োজকেরা জানতে চান, আজ কতজন আলাদা মানুষ এলেন — কেউ তিনবার ঢুকলেও একবার গোনা। নিখুঁত উপায় একটিই: প্রত্যেকের পরিচয় খাতায় লেখা, আর নতুন কেউ এলে পুরো খাতায় খোঁজা। এখন খাতাটিকে একটি সার্ভারের memory ভাবুন, আর মানুষগুলোকে একটি অ্যাপের কোটি কোটি ব্যবহারকারী — প্রতিটি পাতা আর প্রতিটি দিনের জন্য আলাদা খাতা।

বড় সিস্টেমে তিন ধরনের প্রশ্ন বারবার আসে, আর তিনটিরই নিখুঁত উত্তরে প্রতিটি আলাদা জিনিস মনে রাখতে হয়:

প্রশ্ননিখুঁত উপায়আনুমানিক কাঠামোভুলের ধরন
এই key কি set-এ আছে? (membership)সব key-এর hash setBloom filterশুধু ভুল “আছে”
এই key কতবার এসেছে? (frequency)প্রতিটি key-এর counterCount-min sketchশুধু বেশি দেখানো
কতগুলো আলাদা key? (cardinality)সব key-এর set, তারপর গোনাHyperLogLogদুই দিকেই, ছোট ও জানা মাত্রায়

একটি হিসাব করে দেখুন (পর্ব ৩-এর মতো)। ধরা যাক একটি কাল্পনিক ভিডিও অ্যাপে এক মাসে প্রায় ১০০ কোটি আলাদা device id, প্রতিটি 64-bit বা ৮ byte — শুধু id-গুলোই ৮ GB, hash set-এর pointer, খালি ঘর আর metadata মিলিয়ে মোটামুটি ২০ GB-এর ঘরে। এটি একটি প্রশ্নের জন্য; “প্রতিটি ভিডিওর প্রতিদিনের আলাদা দর্শক” চাইলে লাখো ভিডিও × ৩০ দিন — অসম্ভব। অথচ dashboard-এ “৪৮.৩ লাখ” আর “৪৮.৭ লাখ”-এর ফারাক কারও সিদ্ধান্ত বদলায় না। HyperLogLog প্রতিটি গণনায় প্রায় ১২ KB নেয়, ভুল সাধারণত এক শতাংশের নিচে।

সৎ তুলনা: id-গুলো যদি ঘন পূর্ণসংখ্যা হয় (database-এর auto-increment), তাহলে প্রতি id-এ একটি bit — একটি bitmap — নিখুঁত উত্তর দেয়: ১০০ কোটি bit = ১২৫ MB, সংকুচিত bitmap-এ আরও কম। এলোমেলো string — URL, email, device id — হলে bitmap চলে না; তখনই sketch.

এই পর্বের কাঠামোগুলোকে বলে probabilistic data structure; ছোট সারাংশ রাখে বলে অনেকে বলেন sketch. তিনটি বৈশিষ্ট্য এদের আলাদা করে:

  • Memory আগে থেকে ঠিক করা: আকার ঠিক হয় কত ভুল মেনে নেবেন তা দিয়ে, কত key আসবে তা দিয়ে নয়।
  • ভুলের ধরন আর মাত্রা জানা: “কখনো ভুল ‘নেই’ বলবে না”, “কখনো কম দেখাবে না” — গণিতে প্রমাণিত প্রতিশ্রুতি।
  • মেলানো যায় (mergeable): প্রতিটি সার্ভার বা partition নিজের sketch রাখে, পরে মিলিয়ে পুরো ডেটার sketch.
মূল ধারণা

আনুমানিক কাঠামো মানে “অসাবধান হিসাব” নয়। আপনি একটি চুক্তি করছেন: এতটুকু memory-র বদলে এই ধরনের, এই মাত্রার ভুল। নকশায় সেই চুক্তিটি লিখে রাখুন — কোন প্রশ্ন, কত ভুল, কোন দিকে — আর দেখুন ভুলের দিকটি ব্যবসার জন্য নিরাপদ কি না।

এই কাঠামোগুলো আগে কয়েকবার এসেছে: LSM-tree-এর প্রতিটি SSTable-এর সাথে Bloom filter (পর্ব ১৯), অস্তিত্বহীন key-এর ঢল ঠেকাতে Bloom filter (পর্ব ২৮, পর্ব ২৯), W-TinyLFU-এর ভেতরের count-min sketch (পর্ব ২৯), hot key খোঁজা (পর্ব ৩০), আর stream-এ আলাদা ব্যবহারকারী গোনা (পর্ব ৩৬)। এবার ভেতরটা খোলা যাক।

২ · ভিত্তি hashing — ভালো hash, seed আর double hashing

তিনটি কাঠামোর প্রাণ একটিই: hash function. Hash table-এর মূল কথা — hash কী, collision কেন অনিবার্য, birthday paradox — DSA কোর্সের Hash table পর্বে বিস্তারিত আছে; সেখানে Bloom filter-এর সংক্ষিপ্ত পরিচয়ও আছে। এখানে শুধু সেই শর্তগুলো, যা sketch-এর গণিতকে সত্য রাখে:

  • Deterministic: একই ইনপুটে সবসময় একই ফল — অন্য সার্ভারে, অন্য সংস্করণেও। এই কারণেই HyperLogLog একই id দুবার গোনে না।
  • Uniform: প্রমাণে ধরা হয় প্রতিটি ঘর পড়ার সম্ভাবনা 1/m; hash বাঁকা হলে প্রমাণ খাটে না।
  • প্রায় স্বাধীন: Bloom filter-এর k টি hash বা count-min-এর d টি সারি সম্পর্কহীন হতে হবে; নইলে k টি hash আসলে একটি।
  • দ্রুত, ক্রিপ্টোগ্রাফিক নয়: প্রতি ঘটনায় কয়েকবার hash হয়, তাই MurmurHash বা xxHash-এর মতো দ্রুত hash চলে।

এই পর্বের simulator-গুলোর hash সরল ও পরিচিত: FNV-1a 32-bit (পর্ব ১৬), তারপর MurmurHash3-এর শেষ ধাপ fmix32 — কারণ শুধু FNV-1a-তে প্রায় একই রকম string-এর (user:1, user:2…) ওপরের bit যথেষ্ট মেশে না, আর HyperLogLog ঠিক ওপরের bit-ই দেখে। একটি seed বদলালে একই function থেকে নতুন একটি “স্বাধীন” hash পাওয়া যায়।

Double hashing — দুটি hash থেকে k টি

৭টি বা ১০টি আলাদা hash প্রতিবার হিসাব করা ব্যয়বহুল। Adam Kirsch ও Michael Mitzenmacher ২০০৬ সালে (ESA সম্মেলনে, “Less Hashing, Same Performance”) দেখান, মাত্র দুটি hash থেকে বানানো অবস্থানগুলোও যথেষ্ট:

gi(x) = ( h1(x) + i · h2(x) ) mod m,   i = 0, 1, …, k − 1

তাঁদের বিশ্লেষণ অনুযায়ী, বড় m-এ এভাবে double hashing করলে Bloom filter-এর false-positive হার প্রায় k টি সত্যিকারের স্বাধীন hash-এর সমান থাকে। বাস্তবে অনেক লাইব্রেরি একটি 64-bit বা 128-bit hash বানিয়ে তার দুই অর্ধেককে h1 আর h2 হিসেবে নেয়। একটি ছোট সতর্কতা: h2 শূন্য হলে k টি অবস্থানই এক হয়ে যায়, তাই কোডে h2-কে বিজোড় করা হয়েছে।

সব জায়গায় একই hash আর একই seed: sketch মেলাতে হলে দুই পাশে হুবহু একই hash function, seed, m আর k লাগে। দুটি service দুই ভাষার দুই রকম hash ব্যবহার করলে sketch দেখতে ঠিক, কিন্তু মেলালে ফল অর্থহীন — কোনো error ছাড়াই। তাই hash-এর নাম, seed আর সংস্করণ sketch-এর metadata-য় রাখুন। প্রতিরক্ষার দিকটিও ভাবুন: hash আগে থেকে জানা থাকলে কেউ ইচ্ছা করে এমন key পাঠাতে পারেন যা filter দ্রুত ভরায় বা একটি counter ফোলায়; ব্যবহারকারীর ইনপুটে তাই গোপন seed-ওয়ালা hash নিরাপদ।

৩ · Bloom filter — bit array, k টি hash আর “নিশ্চিত নেই”

মুদি দোকানের খাতা: পাড়ার দোকানি বাকির খদ্দেরদের পুরো নাম লেখেন না। তাঁর ছোট কার্ডে ১২টি ঘর। কেউ বাকি নিলে তিনি তিনটি নিয়মে (নামের প্রথম অক্ষর, শেষ অক্ষর, অক্ষর-সংখ্যা) তিনটি ঘর বের করে টিক দেন। পরে কেউ জিজ্ঞেস করলে সেই তিনটি ঘর দেখেন: একটিও ফাঁকা থাকলে নিশ্চিত, এঁর বাকি নেই; তিনটিতেই টিক থাকলে “হয়তো আছে” — তখন বড় খাতা খোলেন। অন্যদের টিক মিলে কাকতালীয়ভাবে তিনটি ঘর ভরে যেতে পারে, কিন্তু যাঁর নামে টিক দেওয়া হয়েছিল, তাঁর ঘর কখনো ফাঁকা হয় না।

১৯৭০ সালে Burton H. Bloom Communications of the ACM-এ এই কাঠামোটি প্রকাশ করেন। Bloom filter-এর উপকরণ দুটি: m টি ঘরের একটি bit array, শুরুতে সব 0; আর k টি hash, প্রতিটি একটি key থেকে 0 থেকে m − 1-এর একটি ঘর দেয়।

  • Insert(x): k টি hash-এর প্রতিটি ঘরে bit 1 করা। আগে থেকে 1 থাকলে 1-ই থাকে।
  • Lookup(x): k টি ঘর দেখা। কোনো একটি 0 হলে উত্তর “নিশ্চিত নেই”; সবগুলো 1 হলে “হয়তো আছে”।

কেন “নিশ্চিত নেই” সবসময় সত্য? x ঢোকানোর সময় তার k টি ঘর 1 হয়েছিল, আর কোনো কাজ কখনো bit-কে 0 করে না — তাই false negative অসম্ভব। কিন্তু না-ঢোকানো একটি key-এর k টি ঘর অন্যরা মিলে ভরিয়ে ফেলতে পারে — সেটিই false positive, যেমন নিচের ছবিতে:

Bloom filter — দুটি insert, একটি false positive

m = 12 bit, k = 3 hash · ঘরের নম্বরগুলো উদাহরণের জন্য ধরে নেওয়া

Bloom filter — দুটি key ঢোকানো আর একটি false positive ঢাকা insert · ঘর 1, 5, 9 খুলনা insert · ঘর 3, 5, 10 0 0 1 1 0 2 1 3 0 4 1 5 0 6 0 7 0 8 1 9 1 10 0 11 bit array · m = 12, k = 3 রাজশাহী? lookup · ঘর 1, 3, 10 তিনটি ঘরই 1 → “হয়তো আছে” অথচ রাজশাহী কখনো ঢোকানো হয়নি — false positive
ছবি ৪৫.১ — ঢাকা (নীল) আর খুলনা (সবুজ) ঢোকার পরে ১২টির ৫টি ঘর 1; ঘর ৫ দুজনের ভাগে। রাজশাহীর তিনটি ঘর (কমলা ড্যাশ) কাকতালীয়ভাবে সেই ঘরগুলোতেই পড়েছে, তাই ভুল “হয়তো আছে”. ৫/১২ ঘর ভরা থাকলে এমন ভুলের সম্ভাবনা মোটামুটি (৫/১২)³ ≈ ৭%.

এবার একই ঘটনা ধাপে ধাপে দেখুন — দুটি insert, তারপর তিন রকম lookup:

m = 12 (ঘর 0–11) · k = 3 · ঘরের নম্বর উদাহরণের জন্য ধরে নেওয়া
কাজ
k টি hash → ঘর
Bit array (ঘর 0 → 11)
ঘরগুলোর bit
উত্তর
  1. শুরু। ১২টি ঘরই 0. Filter কখনো key রাখে না, শুধু bit.
  2. Insert ঢাকা। তিনটি hash দিল ঘর ১, ৫ আর ৯; তিনটিই 1 হলো। “ঢাকা” শব্দটি নিজে কোথাও লেখা হলো না।
  3. Insert খুলনা। ঘর ৩, ৫ আর ১০. ঘর ৫ আগেই 1 ছিল (ঢাকার), তাই নতুন 1 হলো শুধু দুটি। এখন ১২টির মধ্যে ৫টি ঘর 1.
  4. Lookup খুলনা। ঘর ৩, ৫, ১০ — তিনটিই 1, উত্তর “হয়তো আছে”, আর খুলনা সত্যিই আছে। ঢোকানো key-এর উত্তর সবসময় এটিই।
  5. Lookup সিলেট। ঘর ২, ৫, ৯. ঘর ২ = 0, তাই প্রথম 0 পেয়েই থামা: “নিশ্চিত নেই”. সিলেট ঢোকানো হলে ঘর ২ অবশ্যই 1 থাকত — এই যুক্তিতে উত্তরটি প্রমাণিত সত্য।
  6. Lookup রাজশাহী — false positive. ঘর ১ ঢাকার, ঘর ৩ আর ১০ খুলনার; তিনটিই 1, তাই “হয়তো আছে”, অথচ রাজশাহী কখনো ঢোকানো হয়নি। দাম: যে ধীর disk বা database এড়াতে filter বসানো, সেখানে একবার অকারণে যাওয়া।

তিনটি জিনিস Bloom filter পারে না। Delete: রাজশাহীকে ঢুকিয়ে আবার মুছতে ঘর ১, ৩, ১০ শূন্য করলে ঢাকা আর খুলনার ঘরও মোছে — তারা false negative পায়। তালিকা: ভেতরে কোন key আছে, বলা যায় না। Range: bit-গুলো এলোমেলো ঘরে, তাই “১০০ থেকে ২০০-এর মধ্যে কেউ আছে?”-র উত্তর নেই।

একটি সুবিধাও আছে: একই m, k আর hash-এর দুটি filter-এর bit-OR নিলে ঠিক দুই set-এর মিলিত filter পাওয়া যায় — Bloom filter-ও মেলানো যায়।

৪ · Bloom filter-এর গণিত — false-positive হার, সেরা k আর প্রতি key-এ কত bit

পরীক্ষা আর ইন্টারভিউতে প্রমাণটি প্রায়ই চাওয়া হয়। ধরে নিন hash-গুলো uniform আর স্বাধীন; n টি key ঢোকানো হয়েছে, m টি bit, k টি hash.

  1. একটি নির্দিষ্ট bit একটি hash-এ 1 না হওয়ার সম্ভাবনা 1 − 1/m.
  2. মোট kn বার hash হয়েছে। সববার সেই bit বাদ পড়ার সম্ভাবনা (1 − 1/m)kn ≈ e−kn/m, কারণ বড় m-এ (1 − 1/m)m ≈ 1/e.
  3. তাই যেকোনো bit 1 হওয়ার সম্ভাবনা, অর্থাৎ ভরা ঘরের অনুপাত, প্রায় 1 − e−kn/m.
  4. একটি না-ঢোকানো key-এর k টি ঘরের সবগুলো 1 হলে তবেই false positive. ঘরগুলোকে প্রায় স্বাধীন ধরলে:
p ≈ ( 1 − e−kn/m )k

এখানে একটি টানাপোড়েন আছে। k বাড়ালে প্রতিটি lookup বেশি ঘর পরীক্ষা করে — একটি 0 পাওয়ার সুযোগ বাড়ে, ভালো। কিন্তু প্রতিটি insert-ও বেশি ঘর ভরায় — filter দ্রুত 1-এ ভরে, খারাপ। মাঝখানে একটি সেরা k আছে। p-কে k-এর সাপেক্ষে ন্যূনতম করলে (ln p-এর অবকলন শূন্য ধরে, প্রতিসাম্য থেকে) পাওয়া যায়:

kopt = (m/n) · ln 2 ≈ 0.693 · (m/n)
তখন ভরা ঘরের অনুপাত = ½, আর p = (½)k ≈ 0.6185m/n

সুন্দর একটি ফল: সেরা অবস্থায় ঠিক অর্ধেক bit 1 — প্রতিটি bit সর্বোচ্চ তথ্য বহন করে, যেমন একটি ন্যায্য মুদ্রার টস। এবার উল্টো দিক থেকে — লক্ষ্য p দেওয়া থাকলে কত memory লাগবে? kopt বসিয়ে সমাধান করলে:

m/n = − ln p ÷ (ln 2)2 ≈ 1.44 · log2(1/p) bit প্রতি key
লক্ষ্য false positiveপ্রতি key-এ bitসেরা k১ কোটি key১০০ কোটি key
১%৯.৫৯৬.৬৪ → ৭প্রায় ১২.০ MBপ্রায় ১.২০ GB
০.১%১৪.৩৮৯.৯৭ → ১০প্রায় ১৮.০ MBপ্রায় ১.৮০ GB
০.০১%১৯.১৭১৩.২৯ → ১৩প্রায় ২৪.০ MBপ্রায় ২.৪০ GB

মনে রাখার নিয়ম: ভুল ১০ গুণ কমাতে প্রতি key-এ প্রায় ৪.৮ bit বেশি, আর key-এর আকার ভূমিকা রাখে না — ১০ byte-এর id আর ২০০ byte-এর URL, দুটোরই ৯.৬ bit. এবার মেপে দেখুন: নিচের simulator ১০,০০০টি key ঢোকায় (m = ১,০০,০০০, প্রতি key-এ ১০ bit), তারপর ১,০০,০০০টি কখনো-না-ঢোকানো key দিয়ে false positive গোনে, ছয়টি k-এর জন্য — আর প্রতিবার ঢোকানো সব key আবার যাচাই করে, false negative শূন্য কি না।

// Bloom filter: measured false-positive rate vs the formula (1 - e^(-kn/m))^k.
function rng(seed) {                                    // mulberry32: seeded, repeatable
  return () => { seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
function hash32(s, seed) {                              // FNV-1a 32-bit + murmur3 fmix32 finaliser
  let h = (0x811c9dc5 ^ seed) >>> 0;
  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }
  h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35);
  return (h ^ (h >>> 16)) >>> 0;
}
const n = 10000, m = 100000, rand = rng(45);            // 10 bits per element
const members = [], strangers = [];
for (let i = 0; i < n; i++) members.push("user:" + Math.floor(rand() * 1e9));
for (let i = 0; i < 100000; i++) strangers.push("guest:" + i);   // never inserted
const hh = (s) => [hash32(s, 1), (hash32(s, 2) | 1) >>> 0];  // two hashes -> k positions (double hashing)
const H = members.map(hh), G = strangers.map(hh);
console.log(" k   measured FP   formula   members missed");
for (const k of [1, 2, 4, 7, 10, 14]) {
  const bits = new Uint8Array(m);
  for (const [a, b] of H) for (let i = 0; i < k; i++) bits[(a + i * b) % m] = 1;
  let missed = 0, fp = 0;
  for (const [a, b] of H) for (let i = 0; i < k; i++) if (!bits[(a + i * b) % m]) { missed++; break; }
  for (const [a, b] of G) { let all = true;
    for (let i = 0; i < k && all; i++) all = bits[(a + i * b) % m] === 1;
    if (all) fp++; }
  const f = (1 - Math.exp(-k * n / m)) ** k;
  console.log(String(k).padStart(2) + (fp / G.length * 100).toFixed(3).padStart(12) + "%" +
    (f * 100).toFixed(3).padStart(9) + "%" + String(missed).padStart(10));
}
console.log(`\nm/n = ${m / n}: optimal k = (m/n) ln 2 = ${(m / n * Math.LN2).toFixed(2)}`);
for (const p of [0.01, 0.001]) {
  const bpe = -Math.log(p) / Math.LN2 ** 2;
  console.log(`target ${p * 100}%: ${bpe.toFixed(2)} bits per element, k = ${(bpe * Math.LN2).toFixed(2)} -> use ${Math.round(bpe * Math.LN2)}`);
}

আউটপুট:

 k   measured FP   formula   members missed
 1       9.521%    9.516%         0
 2       3.370%    3.286%         0
 4       1.203%    1.181%         0
 7       0.853%    0.819%         0
10       1.054%    1.019%         0
14       1.851%    1.898%         0

m/n = 10: optimal k = (m/n) ln 2 = 6.93
target 1%: 9.59 bits per element, k = 6.64 -> use 7
target 0.1%: 14.38 bits per element, k = 9.97 -> use 10

সূত্র আর মাপ প্রায় মিলে যায়: k = 1-এ ৯.৫২১% বনাম ৯.৫১৬%, k = 7-এ ০.৮৫৩% বনাম ০.৮১৯%. পার্থক্যের কারণ পরীক্ষার এলোমেলোতা, আর সূত্রটি নিজেই আনুমানিক (পরে দেখানো হয়েছে, এটি প্রকৃত হারকে সামান্য কম দেখায়, বিশেষত ছোট m-এ)। Double hashing ব্যবহার করেও ফল সূত্রের কাছাকাছি।

সেরা k সত্যিই মাঝখানে: k = 1 থেকে 7 পর্যন্ত ভুল কমে (৯.৫% → ০.৮৫%), তারপর আবার বাড়ে — k = 10-এ ১.০৫%, k = 14-এ ১.৮৫%. সূত্রের সেরা k = 6.93, অর্থাৎ ৭। Members missed প্রতিটি সারিতে 0 — যেকোনো k-এ ঢোকানো ১০,০০০টি key-এর একটিও “নেই” পায়নি। শেষ দুই লাইন ওপরের টেবিলের আকারের হিসাবই।

বেশি ভরলে ভুল দ্রুত বাড়ে: প্রতি key-এ ১০ bit আর k = 7-এর filter-এ পরিকল্পিত সংখ্যার দেড় গুণ key ঢুকলে সূত্র অনুযায়ী ভুল ০.৮২% থেকে প্রায় ৪.৯%, তিন গুণে প্রায় ৪০% — অথচ কোনো error দেখায় না। তাই নকশায় লিখুন n-এর ঊর্ধ্বসীমা, ভরা bit-এর অনুপাত monitor করুন, আর সীমা পেরোলে বড় করে নতুন filter বানান বা স্তর যোগ করা “scalable” Bloom filter নিন।

৫ · রূপভেদ আর বাস্তব ব্যবহার — counting, cuckoo আর কোথায় বসে

Delete লাগলে — counting Bloom filter আর cuckoo filter

Counting Bloom filter-এ প্রতিটি ঘরে bit-এর বদলে ছোট counter — insert-এ k টি counter বাড়ে, delete-এ কমে। ধারণাটি জনপ্রিয় হয় Fan, Cao, Almeida ও Broder-এর “Summary Cache” গবেষণায় (১৯৯৮ সালে সম্মেলনে, ২০০০ সালে জার্নালে), যেখানে web proxy-গুলো একে অপরের cache-এর সারাংশ বিনিময় করত; ৪-bit counter প্রায় সবসময় যথেষ্ট ছিল — তাতেই memory চার গুণ। ফাঁদ: কখনো না-ঢোকানো কিছু “delete” করলে অন্যের counter কমে, false negative ফিরে আসে।

Cuckoo filter (Fan, Andersen, Kaminsky ও Mitzenmacher, CoNEXT ২০১৪) প্রতিটি key-এর ছোট একটি fingerprint দুটি সম্ভাব্য bucket-এর একটিতে রাখে; জায়গা না থাকলে পুরোনো কাউকে তার বিকল্প bucket-এ সরায় — কোকিলের মতো, যে অন্যের বাসায় ডিম রাখে। Delete-ও চলে। প্রবন্ধের মাপ অনুযায়ী কম ভুলের হারে (মোটামুটি ৩%-এর নিচে) এটি প্রায়ই Bloom filter-এর চেয়ে কম জায়গা নেয়; দাম — প্রায় ভরা হলে insert ব্যর্থ হতে পারে, খুঁটিনাটি বাস্তবায়নভেদে আলাদা।

কোথায় বসে — চারটি প্রচলিত ব্যবহার

  • LSM-tree-এ অকারণ disk-পড়া বাদ: প্রতিটি SSTable-এর সাথে একটি filter; অনুপস্থিত key-এর জন্য ১০টি SSTable-এ ১০টি disk-পড়ার বদলে ১% filter-এ গড়ে প্রায় ০.১টি। কাঠামো আর read amplification-এর হিসাব পর্ব ১৯-এ আছে; এখানে শুধু মনে রাখুন, সেই “প্রতি key-এ ১০ bit-এ প্রায় ০.৮%” ঠিক ওপরের simulator-এর k = 7-এর সারি।
  • Cache-এ “one-hit wonder” ছাঁকা: বড় cache-এ অনেক object জীবনে একবারই চাওয়া হয়, আর প্রথমবারেই রাখলে তারা জনপ্রিয়দের বের করে দেয়। কৌশল: প্রথম অনুরোধে শুধু Bloom filter-এ লিখুন, দ্বিতীয়বার এলে তবেই cache-এ রাখুন; false positive মানে একটি object আগেভাগে ঢুকল — সামান্য ক্ষতি। একটি বড় CDN-এর প্রকাশিত গবেষণাপত্রে (Maggs ও Sitaraman, ২০১৫) এই কৌশলের বর্ণনা আছে। এটি একটি admission policy, W-TinyLFU-এর (পর্ব ২৯) আত্মীয়।
  • Web crawler-এর “দেখা URL”: ১০০ কোটি URL-এর set, গড়ে ধরা যাক ১০০ byte করে, শুধু string-এই প্রায় ১০০ GB. ০.১%-এর Bloom filter-এ প্রায় ১.৮ GB, একটি মেশিনের RAM-এ আঁটে। False positive মানে একটি নতুন পাতা “আগে দেখেছি” ভেবে বাদ — হাজারে একটি পাতা হারানো সাধারণত মেনে নেওয়া যায়, প্রতিটি পাতা দুবার নামানো নয়। Crawler-এর পুরো নকশা আসবে পর্ব ৬৬-এর mock interview-তে।
  • Device-এ স্থানীয় প্রাথমিক যাচাই: কিছু ব্রাউজারের ক্ষতিকর-সাইট সতর্কতার মতো ব্যবস্থায় device বিপজ্জনক URL-এর hash-অংশের একটি সংক্ষিপ্ত তালিকা রাখে, Bloom filter বা তার মতো কাঠামোয়। “নিশ্চিত নেই” হলে সাইট সঙ্গে সঙ্গে খোলে; “হয়তো আছে” হলে তবেই সার্ভারে পূর্ণ যাচাই। বেশিরভাগ পাতায় দেরি শূন্য, আর বেশিরভাগ URL device ছেড়ে যায় না — গোপনীয়তার দিক থেকেও ভালো।

একই ছাঁচ আরও জায়গায়: অস্তিত্বহীন id-এর ঢল (cache penetration) ঠেকানো (পর্ব ২৮); distributed join-এ এক পাশের key-এর filter অন্য পাশে পাঠিয়ে না-মেলা সারি আগেই বাদ (join-এর কৌশলগুলো পর্ব ৩৬-এ); URL shortener-এ নতুন short code-এর দ্রুত যাচাই (পর্ব ৫৪); news feed-এ “আগে দেখানো পোস্ট” (পর্ব ৫৬)।

ব্যবহারFalse positive হলে কী হয়চূড়ান্ত সিদ্ধান্ত filter-এ?
LSM SSTable, cache penetrationএকবার অকারণ disk বা DB-পড়াহ্যাঁ, ক্ষতি শুধু গতিতে
One-hit wonder ছাঁকাএকটি object আগেভাগে cache-এহ্যাঁ
Crawler-এর দেখা URLএকটি নতুন পাতা বাদ পড়েসাধারণত হ্যাঁ, হার মেপে
বার্তার duplicate বাদ দেওয়াএকটি নতুন বার্তা হারায়না — শুধু প্রাথমিক ছাঁকনি (ভাবনার প্রশ্ন ৩)
মূল ধারণা

Bloom filter একটি দারোয়ান, বিচারক নয়। “নিশ্চিত নেই” বললে তার কথাই শেষ; “হয়তো আছে” বললে যেখানে ভুলের দাম আছে, সেখানে আসল উৎসে যাচাই করুন। ভুলের দাম যদি শুধু একটু বাড়তি কাজ হয়, তবেই filter-কে শেষ কথা বলতে দিন।

৬ · Count-min sketch — কতবার এল, কম memory-তে

পাইকারি আড়তের তিনটি টালি খাতা: আড়তে হাজারো খুচরা দোকানদার আসেন; মালিক জানতে চান কে কতবার এলেন, কিন্তু প্রত্যেকের নামে পাতা খোলার জায়গা নেই। তিনজন কর্মচারীর হাতে তিনটি ছোট খাতা, প্রতিটিতে ছয়টি ঘর, আর প্রত্যেকের ঘর বাছার নিজস্ব নিয়ম — কেউ দোকানের নাম, কেউ মোবাইল নম্বর দেখেন। খদ্দের এলে প্রত্যেকে নিজের খাতার সেই ঘরে দাগ দেন। একটি ঘরে কয়েকজনের দাগ মেশে, তাই প্রতিটি খাতার গণনা আসলের সমান বা বেশি — কিন্তু তিন খাতাতেই একই মিশ্রণ কম ঘটে। মালিক তাই তিনটি সংখ্যার সবচেয়ে ছোটটি বিশ্বাস করেন।

Graham Cormode ও S. Muthukrishnan ২০০৪ সালে (LATIN সম্মেলনে; পূর্ণ রূপ Journal of Algorithms, ২০০৫) count-min sketch প্রকাশ করেন। গঠন: d টি সারি, প্রতিটিতে w টি counter, আর প্রতিটি সারির নিজস্ব hash.

  • Update(x, c): প্রতিটি সারি i-তে ঘর hi(x)-এর counter c বাড়ানো (সাধারণত c = 1).
  • Query(x): d টি ঘরের counter-এর সর্বনিম্ন।

প্রতিটি ঘরে x-এর নিজের গণনা, সাথে collision-এ পড়া অন্যদের; শুধু যোগ হয়, তাই প্রতিটি ঘর ≥ আসল, সর্বনিম্নটিও — কখনো কম দেখায় না। N মোট ঘটনা হলে:

w = ⌈ e / ε ⌉,   d = ⌈ ln(1/δ) ⌉ হলে
আসল ≤ অনুমান ≤ আসল + ε·N,  অন্তত 1 − δ সম্ভাবনায়

প্রমাণের ছক: একটি সারিতে x-এর ঘরে অন্য প্রতিটি key পড়ার সম্ভাবনা 1/w (pairwise স্বাধীন hash ধরে), তাই বাড়তির প্রত্যাশিত মান ≤ N/w = ε·N/e. Markov-এর অসমতায় বাড়তি ε·N ছাড়ানোর সম্ভাবনা ≤ 1/e; সর্বনিম্নটি খারাপ হবে শুধু d টি সারিই খারাপ হলে: ≤ (1/e)d = δ. যেমন ε = ০.০০১, δ = ০.০১-এ w = ২,৭১৯, d = ৫ — ১৩,৫৯৫টি counter, ৪ byte করে প্রায় ৫৪ KB, আলাদা key যতই আসুক।

নিচের stepper-এ ছোট একটি sketch (d = 3, w = 6) চারটি ঘটনা আর দুটি query নেয়; সাথে একটি heap সেরা ২টি key ধরে রাখে (ব্যাখ্যা পরে)।

d = 3 সারি, w = 6 ঘর (0–5) · ঘরের নম্বর উদাহরণের জন্য ধরে নেওয়া: a → 1, 4, 0 · b → 3, 4, 5 · c → 1, 2, 5 · d → 1, 4, 5
ঘটনা
সারি 0
সারি 1
সারি 2
এই key-এর অনুমান
Top-2 min-heap (মাথা আগে)
  1. শুরু। ৩ × ৬ = ১৮টি counter, সবই 0; key-এর নাম রাখা হয় না।
  2. +a. তিন সারিতে a-এর ঘর ১, ৪, ০; তিনটিই 1 হলো। a-এর অনুমান min(1, 1, 1) = 1. Heap খালি ছিল, তাই a:1 ঢুকল।
  3. +b. b-এর ঘর ৩, ৪, ৫. সারি ১-এর ঘর ৪ a-এর সাথে ভাগে, তাই সেটি এখন 2. b-এর অনুমান min(1, 2, 1) = 1 — collision-এর 2-কে সর্বনিম্ন বাদ দিল। Heap-এ জায়গা ছিল, b:1 ঢুকল।
  4. +a আবার। সারি ০-এর ঘর ১ → 2, সারি ১-এর ঘর ৪ → 3, সারি ২-এর ঘর ০ → 2. a-এর অনুমান min(2, 3, 2) = 2, heap-এ a-এর মান হালনাগাদ হলো; মাথায় এখন ছোটটি, b:1.
  5. +c. c-এর ঘর ১, ২, ৫: সারি ০-এর ঘর ১ a-এর সাথে ভাগে (এখন 3), সারি ২-এর ঘর ৫ b-এর সাথে (এখন 2). অনুমান min(3, 1, 2) = 1 — সারি ১-এর ফাঁকা ঘর বাঁচাল। Heap ভরা; c:1 মাথার b:1-এর চেয়ে বড় নয়, তাই c ঢুকল না।
  6. Query a. ঘরগুলোতে 3, 3, 2 — সারি ০ আর ১-এ অন্যদের দাগ মিশেছে, সারি ২-এ নয়। সর্বনিম্ন 2, আসলও 2: একটি পরিষ্কার সারিই যথেষ্ট।
  7. Query d — কখনো আসেনি। d-এর তিনটি ঘর (১, ৪, ৫) অন্যদের দিয়ে ভরা: 3, 3, 2. অনুমান 2, আসল 0 — বেশি দেখানো; এত ছোট w-তে স্বাভাবিক, বড় w-তে দ্রুত কমে।

এবার বড় পরিসরে: একটি কাল্পনিক গানের অ্যাপের ২,০০,০০০টি play, জনপ্রিয়তা Zipf-ধাঁচের (r-তম গানের ওজন 1/r, ৫০,০০০টি গান থেকে), ε = ০.০০১, δ = ০.০১. কয়েকটি গানের আসল আর আনুমানিক গণনা, তারপর সবচেয়ে বেশি ভুল বনাম ε·N:

// Count-min sketch over a Zipf-like stream: true vs estimated counts, and the eps*N bound.
function rng(seed) {                                    // mulberry32: seeded, repeatable
  return () => { seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
function hash32(s, seed) {                              // FNV-1a 32-bit + murmur3 fmix32 finaliser
  let h = (0x811c9dc5 ^ seed) >>> 0;
  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }
  h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35);
  return (h ^ (h >>> 16)) >>> 0;
}
const K = 50000, N = 200000, eps = 0.001, delta = 0.01, rand = rng(45);
const w = Math.ceil(Math.E / eps), d = Math.ceil(Math.log(1 / delta));
const cdf = []; let acc = 0;                            // Zipf: rank r chosen with weight 1/r
for (let r = 1; r <= K; r++) { acc += 1 / r; cdf.push(acc); }
const table = Array.from({ length: d }, () => new Uint32Array(w)), truth = new Map();
for (let e = 0; e < N; e++) {
  const x = rand() * acc; let lo = 0, hi = K - 1;
  while (lo < hi) { const mid = (lo + hi) >> 1; if (cdf[mid] < x) lo = mid + 1; else hi = mid; }
  const key = "song:" + (lo + 1);
  truth.set(key, (truth.get(key) || 0) + 1);
  for (let i = 0; i < d; i++) table[i][hash32(key, i + 1) % w]++;   // update: +1 in every row
}
const est = (key) => Math.min(...table.map((row, i) => row[hash32(key, i + 1) % w]));  // query: min
console.log(`w = ${w}, d = ${d}, counters = ${w * d}, distinct keys seen = ${truth.size}`);
console.log("key          true   estimate");
for (const r of [1, 2, 3, 10, 100, 1000]) {
  const key = "song:" + r;
  console.log(key.padEnd(11) + String(truth.get(key) || 0).padStart(6) + String(est(key)).padStart(10));
}
let worst = 0, over = 0, exact = 0;
for (const [key, c] of truth) { const err = est(key) - c;
  worst = Math.max(worst, err); if (err > eps * N) over++; if (err === 0) exact++; }
console.log(`\nbound eps*N = ${eps * N}; worst overestimate = ${worst}; keys above bound = ${over} of ${truth.size}`);
console.log(`keys estimated exactly = ${exact}; never an underestimate (min over rows)`);

আউটপুট:

w = 2719, d = 5, counters = 13595, distinct keys seen = 28633
key          true   estimate
song:1      17527     17549
song:2       8842      8863
song:3       5822      5844
song:10      1774      1803
song:100      183       193
song:1000      29        41

bound eps*N = 200; worst overestimate = 71; keys above bound = 0 of 28633
keys estimated exactly = 4; never an underestimate (min over rows)

সীমা মানা হয়েছে, বেশ ঢিলেভাবেই: প্রতিশ্রুতি ছিল বাড়তি ≤ ε·N = ২০০, ৯৯% সম্ভাবনায়; ২৮,৬৩৩টি key-এর সবচেয়ে খারাপটিও মাত্র ৭১ বেশি, একটিও সীমা ছাড়ায়নি — প্রমাণটি সবচেয়ে খারাপ অবস্থা ধরে।

বড় key-এ নিখুঁতপ্রায়, ছোট key-এ বড় আপেক্ষিক ভুল: সবচেয়ে জনপ্রিয় গান song:1 ১৭,৫২৭ বনাম ১৭,৫৪৯ — ভুল ০.১৩%. কিন্তু song:1000 ২৯ বনাম ৪১ — ৪১% বেশি। কারণ ভুলের সীমা মোট N-এর অনুপাতে, প্রতিটি key-এর নিজের গণনার অনুপাতে নয়। তাই count-min sketch জনপ্রিয় key খোঁজার জন্য দারুণ, লম্বা লেজের ছোট গণনার জন্য নয়।

Heavy hitters আর top-k — sketch-এর সাথে একটি heap

“আজ সবচেয়ে বেশি চালানো ১০টি গান”, “কোন key একটি cache shard-কে গরম করছে” — এগুলো heavy hitters সমস্যা। Count-min sketch key-এর নাম মনে রাখে না, তাই সাথে রাখা হয় k আকারের একটি min-heap (গঠন DSA কোর্সের Heap পর্বে): প্রতিটি ঘটনায় sketch হালনাগাদ, তারপর সেই key-এর অনুমান; key heap-এ থাকলে মান হালনাগাদ, না থাকলে অনুমান heap-এর মাথার (সবচেয়ে ছোট) চেয়ে বড় হলে মাথাটি সরিয়ে নতুনটি। খরচ প্রতি ঘটনায় O(d + log k). এভাবে পাওয়া top-k তালিকা hot key সামলানো (পর্ব ৩০, পর্ব ২৩) আর trending শব্দের (পর্ব ২৭) ভিত্তি।

দুটি উন্নতি প্রচলিত। Conservative update (Estan ও Varghese, ২০০২): শুধু সেই counter-গুলো বাড়ানো, যেগুলো নতুন অনুমানের চেয়ে ছোট — বাড়তি গণনা অনেক কমে, তবে বিয়োগ আর চলে না। আর পুরোনো জনপ্রিয়তা ভোলাতে মাঝে মাঝে সব counter অর্ধেক করা — W-TinyLFU-এর aging (পর্ব ২৯)। মেলানোও সহজ: একই d, w আর hash-এর দুটি sketch ঘর ধরে যোগ করলেই মিলিত ধারার sketch.

৭ · HyperLogLog — কতজন আলাদা, ১২ KB-তে

টসের গল্প: পাড়ার ক্রিকেট মাঠে একজন বললেন, “আজ বিকেলে একবার টানা ১০ বার হেড পড়েছিল।” তাঁর কয়েন না দেখেও আন্দাজ করা যায় তিনি কয়েকশো বার টস করেছেন — টানা ১০ হেডের সম্ভাবনা 1/210 = 1/1024. “সবচেয়ে লম্বা দৌড়” থেকে “মোট চেষ্টা” আন্দাজ — HyperLogLog-এর মূল ভাবনা এটিই।

একটি ভালো hash-এর প্রতিটি bit যেন একটি ন্যায্য টস। একটি hash ঠিক r টি শূন্য দিয়ে শুরু হওয়ার সম্ভাবনা 2−(r+1), আর অন্তত r টি শূন্যের সম্ভাবনা 2−r. তাই n টি আলাদা key-এর hash দেখলে শুরুর শূন্যের সর্বোচ্চ সংখ্যা মোটামুটি log2 n-এর কাছে থাকে। আর একই key আবার এলে hash একই, শূন্যও একই — সর্বোচ্চ বদলায় না। Duplicate আপনাআপনি বাদ পড়ে, তাই কাঠামোটি গোনে শুধু আলাদা key.

একটি সর্বোচ্চ দিয়ে আন্দাজ খুব নড়বড়ে — একটি ভাগ্যবান hash ফল দ্বিগুণ করে দেয়। সমাধান: hash-এর প্রথম p টি bit দিয়ে key-গুলোকে m = 2p টি দলে ভাগ; প্রতিটি দলের একটি register মনে রাখে বাকি bit-এ সর্বোচ্চ ρ (শুরুর শূন্য + ১)। শেষে register-গুলো মেলানো হয় harmonic mean দিয়ে, যা ব্যতিক্রমী বড় মানে কম টলে:

E = αm · m2 ÷ Σj 2−M[j],   αm ≈ 0.7213 / (1 + 1.079/m)
আপেক্ষিক standard error ≈ 1.04 / √m

ইতিহাস ধাপে ধাপে: Flajolet ও Martin-এর probabilistic counting (১৯৮৫), Durand ও Flajolet-এর LogLog (২০০৩, register-এর সাধারণ গড়), আর ২০০৭ সালে Flajolet, Fusy, Gandouet ও Meunier-এর HyperLogLog — harmonic mean, ভুল প্রায় 1.04/√m. Google-এর Heule, Nunkesser ও Hall ২০১৩ সালে HyperLogLog++ প্রকাশ করেন: 64-bit hash, ছোট গণনায় ভুলের বাঁক (bias) সংশোধন, আর অল্প key-এ sparse রূপ।

নিচের stepper-এ p = 4 (১৬টি register) নিয়ে একটি key-এর register হালনাগাদ দেখুন। Hash-এর bit-গুলো এই পর্বের simulator-এর hash থেকে সত্যিকারের হিসাব করা।

p = 4 → m = 16 register (M[0]…M[15]) · hash32 = FNV-1a + fmix32
Key
32-bit hash
Register নম্বর (প্রথম p bit)
ρ = শুরুর শূন্য + 1
Register-গুলো (M[13] বন্ধনীতে)
Σ 2−M[j]
  1. Hash. Key “user:31”-এর 32-bit hash এল। ১৬টি register-এ আগে থেকে কিছু মান আছে — অন্য key-দের রেখে যাওয়া; M[13] = 1, আর Σ 2−M[j] = 8.625.
  2. কোন register? প্রথম p = 4টি bit 1101, দশমিকে ১৩। এই key-এর ভাগ্য তাই শুধু M[13]-এ লেখা হবে — m টি register মানে m টি আলাদা ছোট পরীক্ষা।
  3. ρ গোনা। বাকি bit শুরু হয় 0001-এ — তিনটি শূন্য, তারপর 1, তাই ρ = 4. এমন hash-এর সম্ভাবনা 1/16 — “এই দলে মোটামুটি ১৬টি আলাদা key” ধরনের সাক্ষ্য।
  4. Register = সর্বোচ্চ। M[13] = max(1, 4) = 4. তার অংশ 2−1 = 0.5 থেকে কমে 2−4 = 0.0625, তাই Σ নামল 8.1875-এ, আর E = α·m²/Σ বাড়ল। (এত ছোট m-এ বাস্তবে ছোট-গণনার সংশোধন লাগত; এখানে শুধু দিকটি দেখুন।)
  5. একই key আবার। Hash হুবহু এক, তাই register ১৩ আর ρ = 4 আবার; max(4, 4) = 4 — কিছুই বদলাল না। এটিই duplicate বাদ দেওয়ার পুরো কৌশল: কোনো তালিকা রাখতে হয় না।
  6. অন্য key, একই register. “user:52”-এর hash-ও 1101 দিয়ে শুরু, বাকি অংশ 0010… — ρ = 3. max(4, 3) = 4, অপরিবর্তিত: register মনে রাখে শুধু সবচেয়ে জোরালো সাক্ষ্য।

ছোট আর বড় গণনার সংশোধন

অল্প key-এ অনেক register শূন্য থাকে আর মূল সূত্র বাঁকা ফল দেয়; তাই E ≤ 2.5m হলে আর শূন্য register থাকলে linear counting: E = m · ln(m ÷ V), V = শূন্য register-এর সংখ্যা। খুব বড় গণনায় 32-bit hash-এর নিজের collision-এর জন্য মূল প্রবন্ধে আলাদা সংশোধন আছে; 64-bit hash নিলে সমস্যাটি কার্যত চলে যায়।

Memory আর নির্ভুলতা

64-bit hash-এ ρ সর্বোচ্চ ৬৪-এর কাছে, তাই প্রতি register-এ ৬ bit যথেষ্ট:

pm = 2pMemory (৬ bit/register)Standard error 1.04/√m
১০১,০২৪৭৬৮ byte৩.২৫%
১২৪,০৯৬৩ KB১.৬৩%
১৪১৬,৩৮৪১২ KB (১২,২৮৮ byte)০.৮১%
১৬৬৫,৫৩৬৪৮ KB০.৪১%

নিয়ম: memory ৪ গুণ করলে ভুল অর্ধেক (√m-এর কারণে)। আর memory গণনার আকারের ওপর প্রায় নির্ভর করে না — ১০ হাজার আর ১০০ কোটি, দুটোর জন্যই একই ১২ KB. Standard error মানে সর্বোচ্চ ভুল নয়: ভুলের বণ্টন মোটামুটি normal ধরলে প্রায় ৬৮% ক্ষেত্রে ভুল ±১ standard error-এর মধ্যে, প্রায় ৯৫% ক্ষেত্রে ±২-এর মধ্যে।

এখন মেপে দেখুন: p = 10 আর p = 14-এর sketch-এ ১০ হাজার, ১ লাখ আর ১০ লাখ আলাদা id, তারপর merge — A-তে id ০ থেকে ৫,৯৯,৯৯৯, B-তে ৪,০০,০০০ থেকে ৯,৯৯,৯৯৯, মিলিত আলাদা id ঠিক ১০ লাখ। সরলতার জন্য কোডে প্রতি register-এ এক byte; বাস্তব ব্যবস্থা ৬ bit-এ ঠাসে।

// HyperLogLog: estimate distinct counts with 2^p registers, then merge two sketches.
function rng(seed) {                                    // mulberry32: seeded, repeatable
  return () => { seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
function hash32(s) {                                    // FNV-1a 32-bit + murmur3 fmix32 finaliser
  let h = 0x811c9dc5;
  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }
  h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35);
  return (h ^ (h >>> 16)) >>> 0;
}
const salt = Math.floor(rng(451)() * 1e9);               // seeded prefix: ids "salt:i" are all distinct
const sketch = (p) => ({ p, reg: new Uint8Array(1 << p) });
function add(S, h) { const idx = h >>> (32 - S.p);    // first p bits choose the register
  const rho = Math.min(Math.clz32((h << S.p) >>> 0), 32 - S.p) + 1;  // leading zeros of the rest + 1
  if (rho > S.reg[idx]) S.reg[idx] = rho;               // register keeps the maximum
}
function count(S) {
  const m = S.reg.length; let sum = 0, zeros = 0; for (const r of S.reg) { sum += 2 ** -r; if (r === 0) zeros++; }
  const E = (0.7213 / (1 + 1.079 / m)) * m * m / sum;   // alpha_m * m^2 / sum(2^-M[j]) (harmonic mean)
  return E <= 2.5 * m && zeros > 0 ? m * Math.log(m / zeros) : E;  // small-range: linear counting
}
const fill = (lo, hi, ...S) => { for (let i = lo; i < hi; i++) { const h = hash32(salt + ":" + i); for (const s of S) add(s, h); } };
const pct = (e, t) => ((e - t) / t * 100).toFixed(2).padStart(7) + "%";
console.log("distinct     p=10 (m=1024)          p=14 (m=16384)");
for (const N of [10000, 100000, 1000000]) {
  const a = sketch(10), b = sketch(14); fill(0, N, a, b);
  console.log(String(N).padStart(8) + String(Math.round(count(a))).padStart(10) + pct(count(a), N) +
    String(Math.round(count(b))).padStart(12) + pct(count(b), N));
}
console.log(`theory: 1.04/sqrt(m) = ${(104 / 32).toFixed(2)}% (p=10), ${(104 / 128).toFixed(2)}% (p=14)`);
const A = sketch(14), B = sketch(14); fill(0, 600000, A); fill(400000, 1000000, B);   // overlap 200k
const U = sketch(14); U.reg = A.reg.map((r, j) => Math.max(r, B.reg[j]));           // merge = max
console.log(`\nA ~ ${Math.round(count(A))} (true 600000), B ~ ${Math.round(count(B))} (true 600000)`);
console.log(`merged A u B ~ ${Math.round(count(U))} (true 1000000), error ${pct(count(U), 1e6).trim()}`);

আউটপুট:

distinct     p=10 (m=1024)          p=14 (m=16384)
   10000     10120   1.20%       10067   0.67%
  100000     96156  -3.84%       99248  -0.75%
 1000000   1041952   4.20%     1001281   0.13%
theory: 1.04/sqrt(m) = 3.25% (p=10), 0.81% (p=14)

A ~ 602261 (true 600000), B ~ 606201 (true 600000)
merged A u B ~ 1001281 (true 1000000), error 0.13%

ভুল তত্ত্বের মাপেই: p = 10-এ ভুল ১.২০%, −৩.৮৪%, ৪.২০% — তাত্ত্বিক ৩.২৫%-এর আশপাশে, দুটি একটু বাইরে, যা স্বাভাবিক (প্রায় এক-তৃতীয়াংশ ক্ষেত্রে ভুল ±১ standard error ছাড়ায়)। p = 14-এ ০.৬৭%, −০.৭৫%, ০.১৩% — সবই ০.৮১%-এর মধ্যে, ১৬ গুণ memory-তে ৪ গুণ নির্ভুল। ১০ হাজারের সারিতে p = 14-এর উত্তর এসেছে linear counting থেকে, কারণ সেখানে মূল সূত্রের অনুমান 2.5 × 16,384 = ৪০,৯৬০-এর নিচে ছিল আর শূন্য register-ও ছিল। Seed বদলালে সংখ্যাগুলো বদলাবে, কিন্তু মাত্রা একই থাকবে।

Merge হারায় না কিছুই: A আর B-এর register-এর max নেওয়ার পরের অনুমান 1001281 — হুবহু সেই সংখ্যা, যা ১০ লাখ id একটি sketch-এ ঢোকালে এসেছিল (p = 14, ১০ লাখের সারি)। দুই দলের সর্বোচ্চের সর্বোচ্চ = মিলিত দলের সর্বোচ্চ, তাই মাঝের ২ লাখ id দুবার গোনা হয়নি।

Merge — distributed গণনার আসল শক্তি

এই মেলানোর ক্ষমতাই HyperLogLog-কে distributed সিস্টেমে অপরিহার্য করেছে। প্রতিটি সার্ভার, partition বা ঘণ্টা নিজের ১২ KB-র sketch রাখে; ২৪টি ঘণ্টার sketch মেলালে দিনের, ৩০টি দিনের মেলালে মাসের আলাদা ব্যবহারকারী — কাঁচা ডেটা আবার না পড়েই। দৈনিক গণনার যোগফল মাসিক আলাদা ব্যবহারকারী নয় (একই মানুষ ৩০ বার গোনা পড়বেন); register-এর max এই ভুল এড়ায়। Max নেওয়া commutative, associative আর idempotent — ক্রম যা-ই হোক, একই sketch দুবার মেলালেও ফল এক; তাই retry বা duplicate বার্তায় (পর্ব ৩৪) ভয় নেই, আর এই তিন গুণই পরের পর্বের CRDT-এর ভিত্তি (পর্ব ৪৬)। তবে intersection — “দুই দিনেই এসেছেন কতজন” — এত সহজ নয় (ভাবনার প্রশ্ন ৪)।

Open-source Redis-এ HyperLogLog একটি data type: PFADD যোগ, PFCOUNT গণনা, PFMERGE মেলানো। Redis-এর documentation অনুযায়ী (লেখার সময়ে) প্রতিটি key সর্বোচ্চ প্রায় ১২ KB নেয়, standard error ০.৮১% — অর্থাৎ p = 14; অল্প উপাদানে sparse রূপে আরও কম। খুঁটিনাটি সংস্করণভেদে আলাদা হতে পারে।

# daily unique users, then a weekly merge (illustrative only)
PFADD uv:2026-10-27 user:31 user:52 user:7
PFADD uv:2026-10-28 user:31 user:99
PFCOUNT uv:2026-10-28                          # about 2
PFMERGE uv:week:44 uv:2026-10-27 uv:2026-10-28
PFCOUNT uv:week:44                             # about 4 (user:31 counted once)

৮ · অন্যান্য sketch, বাছাই আর ইন্টারভিউতে আকার ঠিক করা

আরও দুটি পরিবার নাম জানার মতো। Quantile sketch: শত সার্ভারের latency-র p99 চাই, কিন্তু সার্ভারগুলোর p99-এর গড় মিলিত p99 নয় (পর্ব ৪); Ted Dunning-এর t-digest (আর KLL, DDSketch-এর মতো কয়েকটি) মেলানো যায় এমন সংক্ষিপ্ত সারাংশ থেকে percentile আন্দাজ করে। Similarity sketch: Andrei Broder-এর ১৯৯৭ সালের MinHash প্রতিটি set-এর কয়েকশো hash-এর সর্বনিম্ন মান রাখে; দুই set-এর কত ভাগ সর্বনিম্ন মিলল, সেটিই তাদের মিলের (Jaccard similarity) আনুমানিক মান — প্রায়-একই web পাতা খোঁজার জন্য তৈরি।

কাঠামোপ্রশ্নভুলআকারের সূত্রMergeDelete
Bloom filterআছে কি নাশুধু false positive1.44·log2(1/p) bit/keybit-ORনা
Counting Bloom / cuckooআছে কি নাশুধু false positiveBloom-এর কয়েক গুণ / fingerprint-ভিত্তিকশর্তসাপেক্ষহ্যাঁ
Count-min sketchকতবারশুধু বেশি, ≤ ε·N⌈e/ε⌉ × ⌈ln(1/δ)⌉ counterঘর ধরে যোগবিয়োগ চলে, শর্তসহ
HyperLogLogকতজন আলাদাদুই দিকে, ≈ 1.04/√m2p × 6 bitঘর ধরে maxনা
t-digest ইত্যাদিp50, p99প্রান্তে কমগুচ্ছ-সংখ্যা অনুযায়ীহ্যাঁনা
MinHashদুই set কতটা মেলে≈ 1/√(hash-সংখ্যা)প্রতি set-এ কয়েকশো মানঘর ধরে minনা

নিচের ছবিতে দুটি সাধারণ জায়গা পাশাপাশি — অনুরোধের পথে সামনে বসা Bloom filter, আর analytics-এর পথে প্রতিটি partition-এ sketch, শেষে merge:

একটি সিস্টেমে sketch কোথায় বসে

Client → service-এর Bloom filter → (হয়তো আছে) cache/DB · events → partition-প্রতি HLL + CMS → merge → dashboard

Probabilistic কাঠামো কোথায় বসে — অনুরোধের পথ আর analytics-এর পথ ক · অনুরোধের পথ — Bloom filter সামনে Client GET /item/id API service RAM-এ Bloom filter (বৈধ id) নিশ্চিত নেই 404, DB-তে যাওয়া নেই হয়তো আছে cache → database যাচাই খ · analytics-এর পথ — প্রতিটি partition-এ sketch অ্যাপের ঘটনা play, click — user_id, song_id partition 0 HLL + CMS partition 1 HLL + CMS partition 2 HLL + CMS Merge HLL: ঘর ধরে max · CMS: ঘর ধরে যোগ Dashboard আজ আলাদা ব্যবহারকারী ≈ · সেরা ১০ গান
ছবি ৪৫.২ — ওপরে Bloom filter দারোয়ান: “নিশ্চিত নেই” হলে database-এর দরজাই খোলে না। নিচে প্রতিটি partition নিজের ছোট sketch রাখে; merge ধাপে HyperLogLog-এর register-এর max আর count-min-এর counter-এর যোগফল। Partition বাড়লেও প্রতিটির memory একই।

ইন্টারভিউতে আকার ঠিক করার পাঁচ ধাপ

  1. প্রশ্নটি চিনুন: আছে কি না, কতবার, কতজন আলাদা, নাকি percentile?
  2. পরিমাণ বলুন: n বা N, আর কতগুলো sketch — প্রতি পাতা, প্রতি দিন।
  3. ভুলের মাত্রা আর দিক: কত শতাংশ, আর কোন দিকের ভুলে কার ক্ষতি।
  4. সূত্রে বসান: Bloom-এ m = 1.44·n·log2(1/p), count-min-এ ⌈e/ε⌉ × ⌈ln(1/δ)⌉, HyperLogLog-এ 2p × 6 bit — তারপর sketch-সংখ্যা দিয়ে গুণ।
  5. জীবনচক্র: কোথায় থাকে, কীভাবে মেলে, restart-এ হারায় কি না, পুরোনোটা কখন বাদ, আর n সীমা পেরোলে কী।

পাঁচ ধাপের একটি পূর্ণ হিসাব — crawler-এর filter, count-min আর লাখো HyperLogLog — অনুশীলন ১-এ আছে।

আরও গভীরে: Broder ও Mitzenmacher-এর সমীক্ষা “Network Applications of Bloom Filters” (২০০৪ সালের দিকে প্রকাশিত) নেটওয়ার্কে Bloom filter-এর বহু ব্যবহার এক জায়গায় দেখায়। Bloom filter তথ্যতত্ত্বের ন্যূনতম log2(1/p) bit-এর প্রায় ১.৪৪ গুণ নেয়; xor filter (Graf ও Lemire, ২০২০) আর ribbon filter-এর মতো নতুন static filter এই ব্যবধান অনেক কমায়। Heavy hitters-এর নির্ভুল-সীমার deterministic algorithm — Misra–Gries (১৯৮২) আর Space-Saving (Metwally ও সহকর্মীরা, ২০০৫) — count-min-এর বিকল্প হিসেবে পড়ার মতো।

পরীক্ষা ও ইন্টারভিউতে — বিশ্ববিদ্যালয়, চাকরি বা system design ইন্টারভিউর পরীক্ষায় এই ধরনের প্রশ্ন আসতে পারে:

  • Bloom filter-এর false-positive সূত্র প্রমাণ করুন, সেরা k কত? — p ≈ (1 − e−kn/m)k; kopt = (m/n)·ln 2, তখন অর্ধেক bit 1.
  • ১ কোটি key, ১% false positive — কত memory? — প্রতি key-এ প্রায় ৯.৬ bit, মোট প্রায় ১২ MB, ৭টি hash.
  • Bloom filter থেকে delete কেন চলে না, বিকল্প কী? — একটি bit একাধিক key-এর হতে পারে, শূন্য করলে false negative; counting Bloom বা cuckoo filter.
  • Count-min sketch কেন কখনো কম দেখায় না? — প্রতিটি ঘরে আসল গণনা + অন্যদের collision, শুধু যোগ হয়; সর্বনিম্নও তাই ≥ আসল।
  • কয়েকটি সার্ভারের HyperLogLog থেকে মোট আলাদা ব্যবহারকারী কীভাবে? — একই p আর hash-এর sketch-গুলোর register ঘর ধরে max, তারপর গণনা; অনুমানগুলো যোগ করা চলে না।

৯ · AI-এর যুগে sketch-নকশা যাচাই

“আলাদা ব্যবহারকারী আর trending গান কীভাবে গুনব?” — AI সাধারণত বলে “Redis-এর HyperLogLog আর একটি Bloom filter ব্যবহার করুন।” নাম ঠিক, কিন্তু কত ভুল, কোন দিকে, কত memory, কীভাবে মেলাবেন — এগুলো প্রায়ই বাদ পড়ে। সাধারণ ভুলগুলো:

AI-এর ভুলকেন সমস্যাকীভাবে ধরবেন
“Bloom filter-এ false negative-ও হয়” বা “delete করা যায়”মূল প্রতিশ্রুতিই উল্টো বলা; delete করলে সত্যিই false negative আসেনিজে stepper-এর মতো ছোট উদাহরণে bit মুছে দেখুন
সূত্রে bit আর byte মেশানো, বা (ln 2)² বাদ৮ গুণ বা ২ গুণ ভুল memorym/n = −ln p ÷ (ln 2)² নিজে বসিয়ে হিসাব
বিল, ব্যালান্স বা duplicate বাদ দেওয়ার চূড়ান্ত সিদ্ধান্তে sketchভুলের দাম টাকা বা হারানো বার্তাপ্রতিটি ব্যবহারে জিজ্ঞেস করুন: ভুল কোন দিকে, ক্ষতি কার
দৈনিক আলাদা-গণনা যোগ করে মাসিকএকই ব্যবহারকারী বহুবার গোনাRegister-এর merge, অনুমানের যোগ নয়
Product-এর memory বা ভুলের সীমা বানিয়ে বলাসংস্করণভেদে আলাদা, প্রায়ই ভুল সংখ্যাOfficial documentation, আর ছোট load test-এ মেপে দেখা
সব জায়গায় sketch — ১০ হাজার key-এওঅপ্রয়োজনীয় জটিলতা, নিখুঁত উত্তর সস্তা ছিলনিখুঁত উপায়ের memory আগে হিসাব করুন

নিচের prompt-এ পরিমাণ, ভুলের সীমা আর ব্যর্থতার প্রশ্ন আগেই দেওয়া, যাতে উত্তর যাচাইযোগ্য হয়:

তুমি একজন অভিজ্ঞ backend engineer. একটি কাল্পনিক গানের অ্যাপে দিনে প্রায় ৫ কোটি play-ঘটনা, প্রায় ৮০ লাখ আলাদা ব্যবহারকারী, প্রায় ২০ লাখ গান। ঘটনাগুলো ১২টি stream partition-এ ভাগ হয়ে আসে। দরকার: (১) প্রতিটি গানের দৈনিক আলাদা শ্রোতা, ±২% ভুল চলবে; (২) প্রতি ৫ মিনিটে সেরা ১০০টি গান; (৩) নতুন ব্যবহারকারী একটি গান প্রথমবার শুনছেন কি না, দ্রুত প্রাথমিক যাচাই। ১. প্রতিটি দরকারে কোন কাঠামো, কেন? নিখুঁত উপায়ের memory আগে হিসাব করে দেখাও। ২. প্রতিটির আকার ধাপে ধাপে সূত্রসহ হিসাব করো — bit আর byte আলাদা করে লেখো। ৩. প্রতিটির ভুল কোন দিকে, আর সেই ভুলে কার কী ক্ষতি? ৪. ১২টি partition-এর sketch কীভাবে মেলাবে, partition-এর process restart হলে কী হবে? ৫. কোনো product-এর memory, সীমা বা ডিফল্ট বললে সেটি "যাচাই করতে হবে" বলে চিহ্নিত করো।

AI-এর দেওয়া নকশা হাতে পেলে — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১“আনুমানিক” গণনা কি প্রকৌশলের দুর্বলতা? কেন database গবেষকেরা ১৯৮০-এর দশকেই ইচ্ছা করে ভুল উত্তরের পথে গেলেন?

দুর্বলতা নয় — প্রশ্নটিকে সৎভাবে দেখা। Flajolet ও Martin-এর ১৯৮৫ সালের প্রবন্ধের নামেই উদ্দেশ্য লেখা: “Probabilistic Counting Algorithms for Data Base Applications”. তখনকার একটি বড় সমস্যা ছিল query optimizer: একটি join-এর আগে জানতে হয় একটি কলামে কতগুলো আলাদা মান — অর্থাৎ cardinality — কারণ তা থেকেই ঠিক হয় কোন index বা কোন join-ক্রম সস্তা। নিখুঁত গণনায় পুরো টেবিল sort বা hash করতে হয়, যা হয়তো query-টির চেয়েও দামি। অথচ optimizer-এর দরকার শুধু মাত্রা: ১০০ নাকি ১০ লাখ। কয়েক শতাংশ ভুলে তার সিদ্ধান্ত প্রায় কখনো বদলায় না।

এখানেই মূল চিন্তা: উত্তরের নির্ভুলতা হওয়া উচিত সিদ্ধান্তের সংবেদনশীলতার সমান। কিন্তু নিখুঁত হতে গেলে প্রতিটি পাতা, প্রতিটি দিন, প্রতিটি দেশের জন্য GB-এর পর GB memory, আর partition জুড়ে set মেলাতে নেটওয়ার্কে বিশাল স্থানান্তর। Sketch সেই খরচকে প্রতি গণনায় কয়েক KB-তে নামায়, আর merge যোগ করে এমন একটি ক্ষমতা যা নিখুঁত set-এর পক্ষে সস্তায় দেওয়া কঠিন।

তারপরের ইতিহাস এই চিন্তারই পরিশোধন — LogLog, HyperLogLog, HyperLogLog++ — আর আজ বহু database ও analytics engine-এ “approx distinct” ধরনের function আছে, নাম আর খুঁটিনাটি পণ্যভেদে আলাদা।

তবে সীমারেখা স্পষ্ট রাখতে হয়। যেখানে প্রতিটি একক জরুরি — একজন গ্রাহকের মাসিক বিল, একটি অ্যাকাউন্টের ব্যালান্স, আইনগত report — সেখানে আনুমানিক উত্তর গ্রহণযোগ্য নয়। ভালো নকশা দুটোই রাখে: দ্রুত, সস্তা, আনুমানিক পথ দেখার জন্য, আর ধীর, নিখুঁত পথ হিসাবের জন্য — যেমন পর্ব ৩৬-এর stream আর batch.

মূল উপলব্ধি: নির্ভুলতা একটি খরচ, যার দাম memory আর সময়ে দিতে হয়। সিদ্ধান্তের জন্য যতটুকু নির্ভুলতা লাগে, ঠিক ততটুকু কিনুন — আর কোথায় এক টাকার ভুলও চলে না, তা আলাদা করে চিহ্নিত রাখুন।

প্র ০২সেরা অবস্থায় Bloom filter-এর ঠিক অর্ধেক bit কেন 1? আর ১% ভুলে প্রতি key-এ ৯.৬ bit — এর চেয়ে কম কি সম্ভব?

প্রথম প্রশ্নের উত্তর গণিতে, কিন্তু অন্তর্দৃষ্টিটি সরল। একটি ভরা-ঘরের অনুপাত q হলে false positive হয় qk. Filter খুব ফাঁকা হলে (q ছোট) প্রতিটি bit প্রায় সবসময় 0 — একটি lookup যা জানে, তা আগেই অনুমানযোগ্য, অর্থাৎ bit-গুলো তথ্য কম বহন করছে; আমরা আরও hash ব্যবহার করে ফাঁকা জায়গা কাজে লাগাতে পারতাম। খুব ভরা হলে (q কাছাকাছি 1) প্রায় সব lookup-ই “হয়তো আছে” — আবারও bit-গুলো প্রায় অনুমানযোগ্য। তথ্যতত্ত্বে একটি bit সবচেয়ে বেশি তথ্য বহন করে যখন 0 আর 1 সমান সম্ভাব্য। সূত্রে k = (m/n) ln 2 বসালে ঠিক তাই হয়: 1 − e−ln 2 = ½. তখন p = (½)k — প্রতিটি বাড়তি hash ভুলকে অর্ধেক করে, ঠিক একটি করে bit-এর তথ্যের মতো।

দ্বিতীয় প্রশ্ন: তথ্যতত্ত্বের একটি নিম্নসীমা আছে। n টি key-এর যেকোনো set-কে p ভুলের হারে প্রকাশ করতে যেকোনো কাঠামোকেই প্রতি key-এ অন্তত প্রায় log2(1/p) bit নিতে হয় — ১%-এ প্রায় ৬.৬৪ bit. Bloom filter নেয় ৯.৫৯, অর্থাৎ প্রায় ১.৪৪ গুণ (ঠিক 1/ln 2).

গবেষণা এই ব্যবধান কমিয়েছে। Cuckoo filter (২০১৪) fingerprint রাখে, আর কম ভুলের হারে প্রায়ই Bloom-এর চেয়ে কম জায়গা নেয়। Xor filter (২০২০) আর ribbon filter-এর মতো static কাঠামো — যেখানে সব key আগে থেকে জানা, পরে আর যোগ হয় না — নিম্নসীমার আরও কাছে পৌঁছায়, প্রবন্ধগুলোর দাবি অনুযায়ী মোটামুটি ১.০–১.২৫ গুণের ঘরে। দাম: বানানো জটিল, আর বানানোর পরে নতুন key যোগ করা যায় না।

তবু Bloom filter টিকে আছে সরলতার জন্য — কয়েক লাইনের কোড, যেকোনো সময় insert, OR দিয়ে merge; ৪৪% বাড়তি memory অনেক সিস্টেমে সস্তা দাম।

মূল উপলব্ধি: “অর্ধেক bit ভরা” মানে প্রতিটি bit সর্বোচ্চ তথ্য বহন করছে। আর প্রতিটি কাঠামোর পাশে তার তাত্ত্বিক সীমা জানলে বোঝা যায়, আরও ভালো কিছু খুঁজবেন, নাকি সরলতার দামে বর্তমানটিই যথেষ্ট।

প্র ০৩একটি message consumer duplicate বার্তা বাদ দিতে প্রতিটি message id একটি Bloom filter-এ রাখতে চায় — memory কম, দ্রুত। সমস্যা কোথায়, আর ঠিক কীভাবে ব্যবহার করলে নিরাপদ?

সমস্যা ভুলের দিকে। Filter কখনো ঢোকানো id-কে “নেই” বলে না, কিন্তু কখনো কখনো একটি নতুন id-কে “আছে” বলে। Dedup-এর ভাষায়, filter-কে চূড়ান্ত বিচারক করলে প্রতিটি false positive মানে একটি সত্যিকারের নতুন বার্তা duplicate ভেবে নিঃশব্দে ফেলে দেওয়া। দিনে ১ কোটি বার্তায় ১% false positive — প্রায় এক লাখ বার্তা হারানো, যেখানে duplicate হয়তো ছিল মাত্র কয়েক হাজার। এই ঝুঁকির বিস্তারিত পর্ব ৩৪-এ।

নিরাপদ ব্যবহার: filter শুধু প্রাথমিক ছাঁকনি। “নিশ্চিত নেই” — বার্তাটি অবশ্যই নতুন, সরাসরি প্রক্রিয়া করুন আর id-টি filter ও আসল dedup store দুটোতেই লিখুন। “হয়তো আছে” — আসল store-এ (ডেটাবেসের unique key, বা disk-ভিত্তিক store) যাচাই করুন। হিসাবটি দেখুন: ধরা যাক দিনে ১ কোটি বার্তার ০.১% duplicate. নতুন ৯৯,৯০,০০০টির ৯৯% — ৯৮,৯০,১০০টি — filter-এর “নেই” পেয়ে সরাসরি যায়; store-এ যাচাই লাগে শুধু ৯৯,৯০০টি false positive আর ১০,০০০টি সত্যিকারের duplicate মিলিয়ে ১,০৯,৯০০টির, অর্থাৎ মোটের প্রায় ১.১%. সিদ্ধান্ত নির্ভুল, অথচ ধীর store-এ চাপ প্রায় ৯০ গুণ কম।

দ্বিতীয় সমস্যা সময়। Dedup-এর একটি window থাকে — ধরুন ২৪ ঘণ্টা — কিন্তু Bloom filter থেকে পুরোনো id মোছা যায় না। প্রচলিত কৌশল সময়ের ভাগে কয়েকটি filter ঘোরানো: প্রতি ঘণ্টায় একটি নতুন filter, ২৪ ঘণ্টার পুরোনোটি ফেলে দেওয়া। কিন্তু lookup-কে তখন সবগুলো দেখতে হয়, আর ভুলের হার জমে: প্রতিটি ১% হলে ২৪টির যেকোনো একটিতে false positive-এর সম্ভাবনা 1 − 0.9924 ≈ ২১%! তাই প্রতিটি ছোট filter-কে অনেক কম ভুলের জন্য বানাতে হয়, বা কম ভাগে (যেমন “বর্তমান” আর “আগের” দুটি filter, মিলিত ভুল প্রায় ২%) ঘোরাতে হয়।

মূল উপলব্ধি: আনুমানিক কাঠামো ব্যবহারের আগে জিজ্ঞেস করুন, ভুলটি কোন দিকে হবে আর সেই দিকের ক্ষতি কে বহন করবে। যেখানে ভুলের দাম আছে, সেখানে filter-কে দারোয়ান বানান, বিচারক নয় — আর জমে থাকা ভুলের হিসাব আলাদা করে করুন।

প্র ০৪HyperLogLog দিয়ে union সহজ — register-এর max. তাহলে “দুই দিনেই এসেছেন কতজন” (intersection) কেন কঠিন?

কারণ HyperLogLog-এর register union-এর তথ্য ধরে, intersection-এর নয়। একটি register-এ থাকে “এই দলে দেখা সবচেয়ে বড় ρ” — দুই দিনের মধ্যে কে কে মিলেছেন, সেই তথ্য সেখানে নেই।

প্রচলিত বিকল্প হলো inclusion–exclusion: |A ∩ B| = |A| + |B| − |A ∪ B|. এই পর্বের simulator-এর সংখ্যা দিয়ে দেখুন: A ≈ ৬,০২,২৬১, B ≈ ৬,০৬,২০১, A ∪ B ≈ ১০,০১,২৮১. তাহলে intersection ≈ ৬,০২,২৬১ + ৬,০৬,২০১ − ১০,০১,২৮১ = ২,০৭,১৮১, যেখানে আসল ২,০০,০০০ — ভুল প্রায় ৩.৬%, যদিও প্রতিটি অনুমান এক শতাংশের মধ্যে ছিল। কেন? তিনটি অনুমানের প্রতিটির নিজস্ব ভুল আছে, প্রতিটি মোটামুটি নিজের আকারের ০.৮১%: ৬ লাখে প্রায় ৪,৯০০, ১০ লাখে প্রায় ৮,১০০. বিয়োগে ভুলগুলো বাতিল হয় না, বরং মোটামুটি একসাথে জমে — আর সেই জমা ভুল ছোট একটি ফলের তুলনায় বড়।

এখন ভাবুন intersection আসলে মাত্র ১,০০০ জন। তিনটি অনুমানের মিলিত ভুল তখনও কয়েক হাজার থেকে দশ হাজারের ঘরে, অর্থাৎ ফল এমনকি ঋণাত্মকও আসতে পারে — অর্থহীন। নিয়মটি: inclusion–exclusion-এর ভুল নির্ভর করে set-গুলোর আকারের ওপর, intersection-এর আকারের ওপর নয়। তাই intersection যত ছোট, আপেক্ষিক ভুল তত বিশাল।

বাস্তবে তিনটি পথ খোলা। (১) Intersection বড় হলে inclusion–exclusion চলে, ভুলের সীমা আলাদা করে লিখে রাখুন। (২) MinHash-ধাঁচের sketch মিলের অনুপাত (Jaccard) আন্দাজ করে; তাকে union-এর আকার দিয়ে গুণ করলে intersection-এর ভালো অনুমান মেলে। Theta sketch-এর মতো কিছু কাঠামো set-ক্রিয়া মাথায় রেখেই বানানো — ক্ষমতা লাইব্রেরিভেদে আলাদা। (৩) নিখুঁত উত্তর জরুরি হলে batch-এ নিখুঁত হিসাব।

মূল উপলব্ধি: প্রতিটি sketch কিছু প্রশ্নের জন্য বানানো। নকশার আগে লিখুন কোন set-ক্রিয়া লাগবে; শুধু union লাগলে HyperLogLog দারুণ, intersection লাগলে অন্য কাঠামো বা নিখুঁত পথ ভাবুন।

প্র ০৫Count-min sketch দিয়ে “সেরা ১০টি” খুঁজতে চান। কিন্তু ভুল যদি মোট N-এর অনুপাতে হয়, ছোট গণনার ওপর এটি কতটা ভরসার? আর বিকল্প কী?

Count-min-এর প্রতিশ্রুতি “বাড়তি ≤ ε·N” — প্রতিটি key-এর নিজের গণনার নয়, মোট ঘটনার অনুপাতে। Simulator-এ এর ফল স্পষ্ট দেখা গেছে: সবচেয়ে জনপ্রিয় গানের ভুল ০.১৩%, কিন্তু হাজারতম গানের ৪১%. ε = ০.০০১ আর N = ২ লাখে সীমা ২০০ — যে key ২০০ বারের কম এসেছে, তার অনুমান তত্ত্বের দিক থেকে প্রায় অর্থহীন হতে পারে। তাই নিয়ম: count-min ভালোভাবে উত্তর দেয় শুধু সেই key-দের জন্য, যাদের গণনা ε·N-এর চেয়ে অনেক বড়।

দ্বিতীয় সমস্যা: sketch key-এর নাম রাখে না, তাই top-k-এর জন্য পাশে heap লাগে, আর heap-এর সিদ্ধান্ত অনুমানের ওপর। দুটি প্রায়-সমান key-এর মধ্যে বাড়তি গণনার কারণে ভুলটি তালিকায় ঢুকতে পারে — “সেরা ১০” মোটামুটি ঠিক থাকে, কিন্তু “৯ নম্বর নাকি ১১ নম্বর” নিয়ে ভরসা চলে না।

উন্নতির পথ কয়েকটি। Conservative update (Estan ও Varghese, ২০০২) শুধু সর্বনিম্ন counter-গুলো বাড়িয়ে বাড়তি গণনা অনেক কমায়। Deterministic algorithm-ও আছে: Misra–Gries (১৯৮২) মাত্র k − 1টি counter রেখে নিশ্চয়তা দেয় যে N/k-এর বেশিবার আসা প্রতিটি key তালিকায় থাকবে; Space-Saving (Metwally, Agrawal ও El Abbadi, ২০০৫) একই ধাঁচে বাস্তবে খুব নির্ভুল, আর key-এর নামও রাখে — তাই অনেক top-k ব্যবস্থায় এটি প্রথম পছন্দ। Count-min-এর সুবিধা অন্যখানে: যেকোনো key-এর গণনা জিজ্ঞেস করা যায় (শুধু শীর্ষগুলোর নয়), আর ঘর ধরে যোগ করে partition মেলানো খুব সরল।

শেষে সময়: “trending” মানে সাম্প্রতিক, তাই window (প্রতি ৫ মিনিটে নতুন sketch, মিলিয়ে শেষ এক ঘণ্টা) বা নিয়মিত অর্ধেক করা (পর্ব ২৯-এর aging).

মূল উপলব্ধি: ভুলের সীমা কিসের অনুপাতে, সেটিই ঠিক করে কাঠামোটি কোন প্রশ্নের উত্তর দিতে পারে। Count-min বড়দের চেনে, ছোটদের নয় — top-k-তে তাকে heap বা Space-Saving-এর মতো সঙ্গী দিন, আর সময়ের window ঠিক করুন।

অনুশীলন

হিসাবগুলো কাগজে করুন, তারপর উত্তর মিলিয়ে নিন। কোড বদলের কাজে আগে অনুমান লিখুন, তারপর চালান।

  1. আকারের হিসাব: একটি কাল্পনিক অনলাইন সংবাদ প্ল্যাটফর্ম তিনটি কাজে sketch চায়। (ক) ১০০ কোটি URL-এর একটি crawler-এর “দেখা URL” filter, ০.১% false positive — কত bit, কত GB, কয়টি hash? (খ) মোট ঘটনার ০.০১% (ε = ০.০০০১) পর্যন্ত বাড়তি গণনা মেনে নিয়ে, ৯৯.৯% নিশ্চয়তায় (δ = ০.০০১) প্রতিটি লেখকের পড়ার সংখ্যা — count-min-এর w, d আর memory (৪ byte counter)? (গ) ১০ লাখ লেখার প্রতিটির দৈনিক আলাদা পাঠক, p = 14-এর HyperLogLog-এ (৬ bit register) দিনে কত memory, আর ২% ভুল মেনে নিলে কোন p যথেষ্ট?

    (ক) m/n = −ln(০.০০১) ÷ (ln 2)² = ৬.৯০৮ ÷ ০.৪৮০৫ ≈ ১৪.৩৮ bit প্রতি URL. m ≈ ১.৪৪ × ১০১০ bit; ÷ ৮ ≈ প্রায় ১.৮০ GB (প্রায় ১.৬৭ GiB). k = ১৪.৩৮ × ০.৬৯৩ ≈ ৯.৯৭ → ১০টি hash. URL-গুলো নিজে রাখলে (গড়ে ১০০ byte ধরে) প্রায় ১০০ GB.

    (খ) w = ⌈e ÷ ০.০০০১⌉ = ⌈২৭,১৮২.৮⌉ = ২৭,১৮৩; d = ⌈ln(১,০০০)⌉ = ⌈৬.৯১⌉ = ৭. মোট ১,৯০,২৮১টি counter × ৪ byte = ৭,৬১,১২৪ byte, প্রায় ৭৬১ KB — লেখক যত লাখই হোক। ε ১০ গুণ ছোট করলে w ১০ গুণ বাড়ে, কিন্তু δ ১০ গুণ ছোট করলে d বাড়ে মাত্র প্রায় ২.৩ সারি — নিশ্চয়তা সস্তা, নির্ভুলতা দামি।

    (গ) প্রতিটি sketch ২১৪ × ৬ bit = ১২,২৮৮ byte; ১০৬ লেখায় দিনে প্রায় ১২.৩ GB. ২% ভুলে: 1.04/√m ≤ ০.০২ → m ≥ ২,৭০৪, তাই p = 12 (m = ৪,০৯৬, ভুল ১.৬৩%) যথেষ্ট — প্রতি লেখায় ৩,০৭২ byte, দিনে প্রায় ৩.১ GB; অল্প-পাঠকের লেখায় sparse রূপে আরও কম।

  2. নকশা সমালোচনা: একটি কাল্পনিক অ্যাপের signup দল প্রস্তাব দিল: “সব username একটি Bloom filter-এ রাখব (১% false positive). নতুন কেউ নাম চাইলে filter ‘হয়তো আছে’ বললে বলব ‘নামটি নেওয়া হয়েছে’, ‘নেই’ বললে সঙ্গে সঙ্গে account তৈরি করব। কেউ account মুছলে filter থেকে নামটি মুছে দেব। Filter থাকবে প্রতিটি app server-এর memory-তে।” অন্তত চারটি সমস্যা আর তার সমাধান লিখুন।
    • False positive-কে চূড়ান্ত ধরা: প্রায় প্রতি ১০০টি খালি নামের একটিকে “নেওয়া” বলা হবে, কেউ কারণ জানবে না। সমাধান: “হয়তো আছে” হলে database-এ যাচাই।
    • “নেই” হলেই account — race condition: দুটি server-এ একই মুহূর্তে একই নাম এলে দুটিই “নেই” বলবে। সমাধান: চূড়ান্ত সত্য database-এর unique constraint; filter-এর “নেই” শুধু দ্রুত UI-ইঙ্গিত।
    • Delete: bit শূন্য করলে অন্য নামের false negative — একটি নেওয়া নামকে “নিশ্চিত খালি” বলা হবে। সমাধান: মোছা নাম filter-এ রেখে দিন (DB যাচাই তো আছেই), নিয়মিত DB থেকে নতুন filter বানান, বা counting/cuckoo filter.
    • প্রতিটি server-এ আলাদা filter: restart-এ হারায়, server-গুলো একে অপরের থেকে পিছিয়ে থাকে। সমাধান: শুরুতে DB বা snapshot থেকে load, নতুন নাম সব server-এ ছড়ানো, আর ভরা-bit monitor করে নতুন আকারে আবার বানানো।

    সংক্ষেপে: filter এখানে গতি বাড়ানোর উপায়, সত্যের উৎস নয় — আর unique index-ওয়ালা টেবিলে খোঁজা এমনিতেই দ্রুত কি না, আগে মেপে দেখুন।

  3. আউটপুট অনুমান: bloom_fp_rate.js-এ শুধু n = 10000-কে n = 20000 করুন (m একই থাকবে)। চালানোর আগে লিখুন: (ক) formula কলামে k = 1, 4 আর 7-এর মান কত হবে? (খ) সেরা k কত দেখাবে? (গ) members missed কলামে কী আসবে? তারপর চালিয়ে মিলিয়ে নিন।

    এখন প্রতি key-এ bit m/n = ৫. (ক) k = 1: 1 − e−0.2 ≈ ০.১৮১৩ → ১৮.১৩%; k = 4: (1 − e−0.8)4 = ০.৫৫০৭৪ ≈ ৯.২০%; k = 7: (1 − e−1.4)7 = ০.৭৫৩৪৭ ≈ ১৩.৭৮%. (খ) kopt = ৫ × ০.৬৯৩ ≈ ৩.৪৭. (গ) সব সারিতে 0 — filter যত ভরাই হোক, ঢোকানো key কখনো “নেই” পায় না। চালালে:

     k   measured FP   formula   members missed
     1      18.121%   18.127%         0
     2      10.910%   10.869%         0
     4       9.009%    9.195%         0
     7      13.781%   13.778%         0
    10      23.149%   23.360%         0
    14      41.683%   41.548%         0
    
    m/n = 5: optimal k = (m/n) ln 2 = 3.47
    target 1%: 9.59 bits per element, k = 6.64 -> use 7
    target 0.1%: 14.38 bits per element, k = 9.97 -> use 10

    শিক্ষা: key দ্বিগুণ হওয়ায় k = 7-এর ভুল ০.৮৫% থেকে ১৩.৭৮%-এ — প্রায় ১৬ গুণ! আর এই ভরাট filter-এ সবচেয়ে কম ভুল এখন k = 4-এ (৯.০১%, সূত্রের ৯.২০%-এর সামান্য নিচে — এলোমেলোতা দুই দিকেই যায়)। শেষ দুই লাইন বদলায়নি, কারণ লক্ষ্য p থেকে প্রতি key-এ bit-এর হিসাব n-এর ওপর নির্ভর করে না।

  4. ভুলের সীমা বদলে দেখুন: count_min_sketch.js-এ eps = 0.001-কে eps = 0.01 করুন। নতুন w আর ε·N-এর সীমা কত? song:1 আর song:1000-এর অনুমান কতটা খারাপ হবে? চালিয়ে দেখুন, আর বলুন কোন ধরনের key-এর জন্য এই ছোট sketch চলে।

    w = ⌈e ÷ ০.০১⌉ = ⌈২৭১.৮⌉ = ২৭২, d একই ৫ — মোট ১,৩৬০টি counter, আগের দশ ভাগের এক ভাগ (৪ byte করে প্রায় ৫.৪ KB). সীমা ε·N = ০.০১ × ২,০০,০০০ = ২,০০০. চালালে:

    w = 272, d = 5, counters = 1360, distinct keys seen = 28633
    key          true   estimate
    song:1      17527     17794
    song:2       8842      9124
    song:3       5822      6088
    song:10      1774      2136
    song:100      183       494
    song:1000      29       358
    
    bound eps*N = 2000; worst overestimate = 984; keys above bound = 0 of 28633
    keys estimated exactly = 0; never an underestimate (min over rows)

    সবচেয়ে খারাপ বাড়তি ৯৮৪, সীমা ২,০০০-এর অর্ধেকেরও কম, আর কোনো key সীমা ছাড়ায়নি — প্রতিশ্রুতি রক্ষা হয়েছে। কিন্তু আপেক্ষিক ভুল দেখুন: song:1 ১৭,৫২৭ বনাম ১৭,৭৯৪, মাত্র ১.৫%; অথচ song:100 ১৮৩ বনাম ৪৯৪ (প্রায় ২.৭ গুণ), আর song:1000 ২৯ বনাম ৩৫৮ — দশ গুণেরও বেশি। এখন একটি key-ও নিখুঁত নয়। অর্থাৎ এই ছোট sketch “সেরা কয়েকটি গান” চেনার জন্য যথেষ্ট, কিন্তু লম্বা লেজের গণনায় অকেজো। Memory ১০ গুণ কমানোর দাম ভুলের সীমা ১০ গুণ — w = e/ε সূত্রের মতোই।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমHyperLogLog-এ একটি key যোগ করা থেকে গণনা পর্যন্ত ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 🔢 Key-এর hash হিসাব করা
  2. 🗂️ Hash-এর প্রথম p টি bit দিয়ে register বাছা
  3. 0️⃣ বাকি bit-এর শুরুর শূন্য গুনে ρ = শূন্য + 1
  4. ⬆️ Register-এ রাখা: max(পুরোনো মান, ρ)
  5. ➗ গণনার সময় Σ 2−M[j] দিয়ে harmonic mean-ভিত্তিক অনুমান
  6. 🔧 ছোট গণনায় শূন্য register থাকলে linear counting দিয়ে সংশোধন

১ কোটি key-এর জন্য ১% false positive-এর একটি Bloom filter বানাতে মোটামুটি কত memory লাগবে? (সূত্র: প্রতি key-এ bit = −ln p ÷ (ln 2)²)

  • প্রায় ১.২ MB
  • প্রায় ১২ MB
  • প্রায় ৯৬ MB
  • প্রায় ১.২ GB

একটি সাধারণ (bit-ভিত্তিক) Bloom filter থেকে একটি key মুছতে তার k টি bit শূন্য করা হলো। সবচেয়ে বড় সমস্যা কী?

  • False positive-এর হার বেড়ে যায়
  • মোছা খুব ধীর, কারণ পুরো array পড়তে হয়
  • অন্য ঢোকানো key-এর ভাগের bit-ও শূন্য হয়ে false negative তৈরি হতে পারে
  • মোছা key-এর নাম filter-এ থেকে যায়

একটি count-min sketch-এ w = ⌈e/ε⌉, d = ⌈ln(1/δ)⌉, ε = 0.001, δ = 0.01, আর মোট ২,০০,০০০টি ঘটনা ঢুকেছে। একটি key-এর আসল গণনা ৫০০ হলে তার অনুমান সম্পর্কে কোনটি ঠিক?

  • সবসময় ৫০০ বা তার বেশি, আর অন্তত ৯৯% সম্ভাবনায় ৭০০-এর বেশি নয়
  • ৩০০ থেকে ৭০০-এর মধ্যে, দুই দিকেই ভুল হতে পারে
  • সবসময় ঠিক ৫০০
  • ৫০০ থেকে ৫০০.৫-এর মধ্যে, কারণ ভুল ০.১%

তিনটি সার্ভার একই hash আর p = 14 দিয়ে নিজ নিজ HyperLogLog রাখে। আজকের মোট আলাদা ব্যবহারকারী পেতে কী করবেন? (একজন ব্যবহারকারী একাধিক সার্ভারে আসতে পারেন।)

  • তিনটি সার্ভারের অনুমান যোগ করা
  • তিনটি অনুমানের গড় নেওয়া
  • Register-গুলো ঘর ধরে যোগ করে তারপর গণনা
  • Register-গুলো ঘর ধরে max নিয়ে একটি sketch বানিয়ে তারপর গণনা

একটি HyperLogLog-এ p = 12 (অর্থাৎ m = 4096 register). আপেক্ষিক standard error মোটামুটি কত? (সূত্র: 1.04/√m)

  • প্রায় ০.৮১%
  • প্রায় ১.৬%
  • প্রায় ৩.২৫%
  • প্রায় ৬.৫%

নিচের কোন কাজে probabilistic কাঠামোকে চূড়ান্ত উত্তর হিসেবে ব্যবহার করা সবচেয়ে অনুচিত?

  • একটি dashboard-এ আজকের আনুমানিক আলাদা দর্শক, HyperLogLog দিয়ে
  • LSM-tree-এ কোন SSTable পড়তে হবে না, Bloom filter দিয়ে ঠিক করা
  • প্রতিটি গ্রাহকের মাসিক API কল গুনে বিল করা, count-min sketch দিয়ে
  • প্রতি ৫ মিনিটে trending ১০টি শব্দ, count-min sketch আর heap দিয়ে

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Sketch একটি চুক্তি: নির্দিষ্ট memory-র বদলে জানা ধরনের, জানা মাত্রার ভুল, আর merge-এর ক্ষমতা। আগে নিখুঁত উপায়ের দাম হিসাব করুন, তারপর বাছুন; ভুলের দিক কার ক্ষতি করে, তা লিখে রাখুন।
  2. Bloom filter: p ≈ (1 − e−kn/m)k, kopt = (m/n) ln 2, m/n = −ln p ÷ (ln 2)² — ১%-এ প্রায় ৯.৬ bit ও ৭ hash, ০.১%-এ প্রায় ১৪.৪ ও ১০. False negative নেই, delete নেই (counting বা cuckoo filter লাগে)।
  3. Count-min sketch: d × w counter, update-এ প্রতি সারিতে যোগ, query-তে min; কখনো কম নয়, বাড়তি ≤ ε·N সম্ভাবনা 1 − δ-তে, w = ⌈e/ε⌉, d = ⌈ln(1/δ)⌉. বড় key-এ ভালো; top-k-এর জন্য সাথে min-heap.
  4. HyperLogLog: প্রথম p bit → register, বাকির শুরুর শূন্য + 1 → ρ, register = max; harmonic mean, ভুল ≈ 1.04/√m — p = 14-এ ১২ KB-তে ০.৮১ শতাংশ। Union = register-এর max, intersection কঠিন।
  5. সবখানে একই hash, seed আর আকার — নইলে merge নিঃশব্দে অর্থহীন; দুটি hash থেকে double hashing-এ k টি।

পরের পর্বে: HyperLogLog-এর register মেলাতে max নিয়েছি — ক্রম যা-ই হোক, যতবারই মেলান, ফল এক। এই গুণটিকে সাধারণ নিয়মে পরিণত করলে কী হয়? পর্ব ৪৬-এ দেখবেন, দুটি replica একই ডেটা আলাদাভাবে বদলালে কীভাবে last-write-wins থেকে CRDT পর্যন্ত নানা কৌশলে দ্বন্দ্ব মেটানো হয়।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান