Skip to content

Commit 419062a

Browse files
authored
Merge pull request #524 from apache/theta-compact-trim
Add optional trim flag to update_theta_sketch::compact()
2 parents 527bd4d + e13672e commit 419062a

5 files changed

Lines changed: 192 additions & 10 deletions

File tree

‎theta/include/theta_helpers.hpp‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,45 @@
2020
#ifndef THETA_HELPERS_HPP_
2121
#define THETA_HELPERS_HPP_
2222

23+
#include <algorithm>
24+
#include <cstdint>
2325
#include <stdexcept>
2426
#include <string>
27+
#include <vector>
2528

2629
#include "theta_constants.hpp"
30+
#include "theta_comparators.hpp"
2731

2832
namespace datasketches {
2933

34+
/**
35+
* Trims a vector of theta entries down to at most nominal_size and returns the theta the
36+
* result must carry.
37+
*
38+
* The entry at index nominal_size becomes the new theta and is itself discarded, so every
39+
* entry kept is strictly below the returned value. That is what keeps the estimator
40+
* unbiased: theta must be an exclusive upper bound on the retained hashes. Getting this
41+
* off by one does not fail loudly, it quietly biases every estimate the sketch produces.
42+
*
43+
* Capacity is released as well as size. Callers reserve an upper bound before filling, so
44+
* without the shrink a trimmed result keeps the untrimmed allocation, which for an update
45+
* sketch just under the rebuild threshold is nearly twice what it reports.
46+
*
47+
* @param entries entries to trim in place; reordered even when nothing is removed
48+
* @param nominal_size the most entries to keep
49+
* @param theta returned unchanged when there is nothing to trim
50+
* @return the theta of the trimmed result
51+
*/
52+
template<typename ExtractKey, typename Entry, typename Allocator>
53+
static uint64_t trim_to_nominal(std::vector<Entry, Allocator>& entries, uint32_t nominal_size, uint64_t theta) {
54+
if (entries.size() <= nominal_size) return theta;
55+
std::nth_element(entries.begin(), entries.begin() + nominal_size, entries.end(), compare_by_key<ExtractKey>());
56+
const uint64_t new_theta = ExtractKey()(entries[nominal_size]);
57+
entries.erase(entries.begin() + nominal_size, entries.end());
58+
entries.shrink_to_fit();
59+
return new_theta;
60+
}
61+
3062
template<typename T>
3163
static void check_value(T actual, T expected, const char* description) {
3264
if (actual != expected) {

‎theta/include/theta_sketch.hpp‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -330,11 +330,25 @@ class update_theta_sketch_alloc: public theta_sketch_alloc<Allocator> {
330330
void reset();
331331

332332
/**
333-
* Converts this sketch to a compact sketch (ordered or unordered).
333+
* Converts this sketch to a compact sketch (ordered or unordered, trimmed or not trimmed).
334+
* This does not modify the source update sketch.
334335
* @param ordered optional flag to specify if an ordered sketch should be produced
336+
* @param trim optional flag to reduce the size of the returned sketch to at most
337+
* the nominal size k, if required. An update sketch retains more than k entries
338+
* between rebuilds; those extra entries below theta improve the estimate, so the
339+
* default is to keep them.
340+
* Trimming is lossy and is never required for correctness. It discards retained
341+
* entries, and since the relative error scales with 1 / sqrt(retained), it always
342+
* degrades accuracy and widens the confidence bounds, whatever mode the source is
343+
* in. Worst case, a sketch grown to just under the rebuild threshold of 15/16 * 2k
344+
* loses nearly half its entries, widening the bounds by about sqrt(15/8), or
345+
* roughly 37%. A sketch in exact mode that retains more than k entries loses
346+
* exactness as well: it is returned in estimation mode, so get_estimate() carries
347+
* error where it would otherwise have returned an exact count.
348+
* Only pass true if a bounded result size matters more than that accuracy.
335349
* @return compact sketch
336350
*/
337-
compact_theta_sketch_alloc<Allocator> compact(bool ordered = true) const;
351+
compact_theta_sketch_alloc<Allocator> compact(bool ordered = true, bool trim = false) const;
338352

339353
virtual iterator begin();
340354
virtual iterator end();
@@ -518,6 +532,7 @@ class compact_theta_sketch_alloc: public theta_sketch_alloc<Allocator> {
518532
template<typename E, typename EK, typename P, typename S, typename CS, typename A> friend class theta_union_base;
519533
template<typename E, typename EK, typename P, typename S, typename CS, typename A> friend class theta_intersection_base;
520534
template<typename E, typename EK, typename CS, typename A> friend class theta_set_difference_base;
535+
template<typename A> friend class update_theta_sketch_alloc;
521536
compact_theta_sketch_alloc(bool is_empty, bool is_ordered, uint16_t seed_hash, uint64_t theta, std::vector<uint64_t, Allocator>&& entries);
522537
};
523538

‎theta/include/theta_sketch_impl.hpp‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
#include <sstream>
2424
#include <vector>
2525
#include <stdexcept>
26+
#include <algorithm>
2627

2728
#include "binomial_bounds.hpp"
2829
#include "theta_helpers.hpp"
@@ -239,8 +240,21 @@ auto update_theta_sketch_alloc<A>::end() const -> const_iterator {
239240
}
240241

241242
template<typename A>
242-
compact_theta_sketch_alloc<A> update_theta_sketch_alloc<A>::compact(bool ordered) const {
243-
return compact_theta_sketch_alloc<A>(*this, ordered);
243+
compact_theta_sketch_alloc<A> update_theta_sketch_alloc<A>::compact(bool ordered, bool trim) const {
244+
if (!trim) return compact_theta_sketch_alloc<A>(*this, ordered);
245+
246+
std::vector<uint64_t, A> entries(table_.allocator_);
247+
if (this->is_empty()) {
248+
return compact_theta_sketch_alloc<A>(true, true, this->get_seed_hash(), this->get_theta64(), std::move(entries));
249+
}
250+
// copy first: this method is const, and quick select would permute the source table
251+
entries.reserve(this->get_num_retained());
252+
std::copy(this->begin(), this->end(), std::back_inserter(entries));
253+
uint64_t theta = this->get_theta64();
254+
const uint32_t nominal_size = 1 << table_.lg_nom_size_;
255+
theta = trim_to_nominal<trivial_extract_key>(entries, nominal_size, theta);
256+
if (ordered) std::sort(entries.begin(), entries.end());
257+
return compact_theta_sketch_alloc<A>(false, ordered, this->get_seed_hash(), theta, std::move(entries));
244258
}
245259

246260
template<typename A>

‎theta/include/theta_union_base_impl.hpp‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
#include <stdexcept>
2525

2626
#include "conditional_forward.hpp"
27+
#include "theta_helpers.hpp"
2728

2829
namespace datasketches {
2930

@@ -70,12 +71,7 @@ CS theta_union_base<EN, EK, P, S, CS, A>::get_result(bool ordered) const {
7071
} else {
7172
std::copy_if(table_.begin(), table_.end(), std::back_inserter(entries), key_not_zero_less_than<uint64_t, EN, EK>(theta));
7273
}
73-
if (entries.size() > nominal_num) {
74-
std::nth_element(entries.begin(), entries.begin() + nominal_num, entries.end(), comparator());
75-
theta = EK()(entries[nominal_num]);
76-
entries.erase(entries.begin() + nominal_num, entries.end());
77-
entries.shrink_to_fit();
78-
}
74+
theta = trim_to_nominal<EK>(entries, nominal_num, theta);
7975
if (ordered) std::sort(entries.begin(), entries.end(), comparator());
8076
return CS(table_.is_empty_, ordered, compute_seed_hash(table_.seed_), theta, std::move(entries));
8177
}

‎theta/test/theta_sketch_test.cpp‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
#include <sstream>
2323
#include <vector>
2424
#include <stdexcept>
25+
#include <algorithm>
2526

2627
#include <catch2/catch.hpp>
2728
#include <theta_sketch.hpp>
@@ -151,6 +152,130 @@ TEST_CASE("theta sketch: single item", "[theta_sketch]") {
151152
REQUIRE(update_sketch.compact(false).is_ordered());
152153
}
153154

155+
TEST_CASE("theta sketch: compact with trim, all four cases", "[theta_sketch]") {
156+
update_theta_sketch update_sketch = update_theta_sketch::builder().build();
157+
for (int i = 0; i < 8000; i++) update_sketch.update(i);
158+
const uint32_t k = 1 << theta_constants::DEFAULT_LG_K;
159+
// an update sketch retains more than k between rebuilds, so trimming has work to do
160+
REQUIRE(update_sketch.get_num_retained() > k);
161+
const uint32_t retained_before = update_sketch.get_num_retained();
162+
const uint64_t theta_before = update_sketch.get_theta64();
163+
164+
// the trimmed result is what trim() + compact() would produce
165+
update_theta_sketch trimmed = update_sketch;
166+
trimmed.trim();
167+
compact_theta_sketch expected = trimmed.compact(true);
168+
const std::vector<uint64_t> expected_entries(expected.begin(), expected.end());
169+
170+
// case 1: ordered, not trimmed (the default) keeps every retained entry
171+
compact_theta_sketch c1 = update_sketch.compact(true, false);
172+
REQUIRE(c1.is_ordered());
173+
REQUIRE(c1.get_num_retained() == retained_before);
174+
REQUIRE(c1.get_theta64() == theta_before);
175+
REQUIRE(std::is_sorted(c1.begin(), c1.end()));
176+
177+
// case 2: unordered, not trimmed
178+
compact_theta_sketch c2 = update_sketch.compact(false, false);
179+
REQUIRE_FALSE(c2.is_ordered());
180+
REQUIRE(c2.get_num_retained() == retained_before);
181+
REQUIRE(c2.get_theta64() == theta_before);
182+
183+
// case 3: ordered and trimmed
184+
compact_theta_sketch c3 = update_sketch.compact(true, true);
185+
REQUIRE(c3.is_ordered());
186+
REQUIRE(c3.get_num_retained() == k);
187+
REQUIRE(c3.get_theta64() < theta_before); // new theta is the k-th smallest hash
188+
REQUIRE(c3.get_theta64() == expected.get_theta64());
189+
REQUIRE(std::is_sorted(c3.begin(), c3.end()));
190+
REQUIRE(std::vector<uint64_t>(c3.begin(), c3.end()) == expected_entries);
191+
// every retained hash is strictly below the new theta
192+
for (auto h: c3) REQUIRE(h < c3.get_theta64());
193+
194+
// case 4: unordered and trimmed: same set and theta, no sort
195+
compact_theta_sketch c4 = update_sketch.compact(false, true);
196+
REQUIRE_FALSE(c4.is_ordered());
197+
REQUIRE(c4.get_num_retained() == k);
198+
REQUIRE(c4.get_theta64() == expected.get_theta64());
199+
std::vector<uint64_t> c4_entries(c4.begin(), c4.end());
200+
std::sort(c4_entries.begin(), c4_entries.end());
201+
REQUIRE(c4_entries == expected_entries);
202+
203+
// the source sketch must be untouched by any of the four
204+
REQUIRE(update_sketch.get_num_retained() == retained_before);
205+
REQUIRE(update_sketch.get_theta64() == theta_before);
206+
}
207+
208+
TEST_CASE("theta sketch: compact with trim widens the bounds in estimation mode", "[theta_sketch]") {
209+
// trimming is lossy even when the source is already estimating: it discards
210+
// retained entries, and the relative error scales with 1 / sqrt(retained)
211+
const uint32_t k = 1 << theta_constants::DEFAULT_LG_K;
212+
update_theta_sketch sketch = update_theta_sketch::builder().build();
213+
for (int i = 0; i < 40000; i++) sketch.update(i);
214+
REQUIRE(sketch.is_estimation_mode());
215+
REQUIRE(sketch.get_num_retained() > k);
216+
217+
compact_theta_sketch plain = sketch.compact(true, false);
218+
compact_theta_sketch trimmed = sketch.compact(true, true);
219+
REQUIRE(trimmed.get_num_retained() == k);
220+
REQUIRE(plain.get_num_retained() > trimmed.get_num_retained());
221+
222+
// fewer retained entries -> strictly wider confidence interval
223+
const double plain_width = plain.get_upper_bound(2) - plain.get_lower_bound(2);
224+
const double trimmed_width = trimmed.get_upper_bound(2) - trimmed.get_lower_bound(2);
225+
REQUIRE(trimmed_width > plain_width);
226+
}
227+
228+
TEST_CASE("theta sketch: compact with trim converts exact mode to estimation", "[theta_sketch]") {
229+
// an update sketch can retain far more than k entries while still in exact mode:
230+
// nothing has been evicted yet, so theta is still 1.0 and the count is exact
231+
const uint32_t k = 1 << theta_constants::DEFAULT_LG_K;
232+
update_theta_sketch sketch = update_theta_sketch::builder().build();
233+
const int n = 5000;
234+
for (int i = 0; i < n; i++) sketch.update(i);
235+
REQUIRE(sketch.get_num_retained() > k);
236+
REQUIRE_FALSE(sketch.is_estimation_mode());
237+
REQUIRE(sketch.get_theta() == 1.0);
238+
239+
// not trimming keeps every entry and the exact count
240+
compact_theta_sketch exact = sketch.compact(true, false);
241+
REQUIRE_FALSE(exact.is_estimation_mode());
242+
REQUIRE(exact.get_num_retained() == static_cast<uint32_t>(n));
243+
REQUIRE(exact.get_estimate() == Approx(n));
244+
245+
// trimming is lossy: it discards real data, lowers theta below 1.0 and the
246+
// result is an estimate carrying error where the source held an exact count
247+
compact_theta_sketch trimmed = sketch.compact(true, true);
248+
REQUIRE(trimmed.is_estimation_mode());
249+
REQUIRE(trimmed.get_num_retained() == k);
250+
REQUIRE(trimmed.get_theta() < 1.0);
251+
REQUIRE(trimmed.get_estimate() != Approx(n));
252+
// the estimate is still sound: n must lie inside the 3-sigma bounds
253+
REQUIRE(trimmed.get_lower_bound(3) <= n);
254+
REQUIRE(trimmed.get_upper_bound(3) >= n);
255+
}
256+
257+
TEST_CASE("theta sketch: compact with trim, empty and below k", "[theta_sketch]") {
258+
// empty: trimming changes nothing
259+
update_theta_sketch empty_sketch = update_theta_sketch::builder().build();
260+
compact_theta_sketch empty_result = empty_sketch.compact(true, true);
261+
REQUIRE(empty_result.is_empty());
262+
REQUIRE(empty_result.get_num_retained() == 0);
263+
REQUIRE(empty_result.is_ordered());
264+
265+
// below k: nothing to trim, theta and entries are preserved
266+
update_theta_sketch small = update_theta_sketch::builder().build();
267+
for (int i = 0; i < 100; i++) small.update(i);
268+
REQUIRE_FALSE(small.is_estimation_mode());
269+
270+
compact_theta_sketch small_result = small.compact(true, true);
271+
REQUIRE_FALSE(small_result.is_estimation_mode());
272+
REQUIRE(small_result.get_num_retained() == 100);
273+
REQUIRE(small_result.get_theta64() == small.get_theta64());
274+
REQUIRE(small_result.get_estimate() == Approx(100.0));
275+
REQUIRE(std::is_sorted(small_result.begin(), small_result.end()));
276+
REQUIRE_FALSE(small.compact(false, true).is_ordered());
277+
}
278+
154279
TEST_CASE("theta sketch: resize exact", "[theta_sketch]") {
155280
update_theta_sketch update_sketch = update_theta_sketch::builder().build();
156281
for (int i = 0; i < 2000; i++) update_sketch.update(i);

0 commit comments

Comments
 (0)