Skip to content

Commit d9a7349

Browse files
authored
Normalize string in trees (#169)
* Normalize strings in tree * code formatting * clean f-strings * fixed typing inconsistencies
1 parent 0da40c4 commit d9a7349

6 files changed

Lines changed: 230 additions & 30 deletions

File tree

syndiffix/microdata.py

Lines changed: 103 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ def create_value_safe_set(self, values: pd.Series) -> None:
5858
def analyze_tree(self, root: Node) -> None:
5959
pass
6060

61+
def denormalize_safe_values(self) -> None:
62+
pass
63+
6164

6265
class BooleanConvertor(DataConvertor):
6366
def __init__(self) -> None:
@@ -71,7 +74,7 @@ def to_float(self, value: Value) -> float:
7174
return 1.0 if value else 0.0
7275

7376
def from_interval(self, interval: Interval, rng: Random) -> MicrodataValue:
74-
value = _generate_float(interval, rng) >= 0.5
77+
value = _generate_random_float(interval, rng) >= 0.5
7578
return (value, 1.0 if value else 0.0)
7679

7780
def create_value_safe_set(self, values: pd.Series) -> None:
@@ -84,7 +87,8 @@ def __init__(self, values: Iterable[Value]) -> None:
8487
super().__init__()
8588
# Fit up to 0.9999 so that the max bucket range is [0-1)
8689
self.scaler = MinMaxScaler(feature_range=(0.0, 0.9999)) # type: ignore
87-
# This value-neutral fitting is only for passing unit tests.
90+
# This value-neutral fitting is only for passing unit tests, gets overridden
91+
# later by fit_transform().
8892
self.scaler.fit(np.array([[0.0], [0.9999]]))
8993
self.final_round_precision = _get_round_precision(cast(Iterable[float], values))
9094

@@ -96,7 +100,7 @@ def to_float(self, value: Value) -> float:
96100
return round(float(value), self.final_round_precision)
97101

98102
def from_interval(self, interval: Interval, rng: Random) -> MicrodataValue:
99-
value = _generate_float(interval, rng)
103+
value = _generate_random_float(interval, rng)
100104
if self.value_safe_flag is True:
101105
value = _convert_to_safe_value(value, self.safe_values)
102106
assert self.scaler is not None
@@ -115,7 +119,8 @@ def __init__(self) -> None:
115119
super().__init__()
116120
# Fit up to 0.9999 so that the max bucket range is [0-1)
117121
self.scaler = MinMaxScaler(feature_range=(0.0, 0.9999)) # type: ignore
118-
# This value-neutral fitting is only for passing unit tests.
122+
# This value-neutral fitting is only for passing unit tests, gets overridden
123+
# later by fit_transform().
119124
self.scaler.fit(np.array([[0.0], [0.9999]]))
120125

121126
def column_type(self) -> ColumnType:
@@ -126,7 +131,7 @@ def to_float(self, value: Value) -> float:
126131
return float(value)
127132

128133
def from_interval(self, interval: Interval, rng: Random) -> MicrodataValue:
129-
value = _generate_float(interval, rng)
134+
value = _generate_random_float(interval, rng)
130135
if self.value_safe_flag is True:
131136
value = _convert_to_safe_value(value, self.safe_values)
132137
assert self.scaler is not None
@@ -144,7 +149,8 @@ def __init__(self) -> None:
144149
super().__init__()
145150
# Fit up to 0.9999 so that the max bucket range is [0-1)
146151
self.scaler = MinMaxScaler(feature_range=(0.0, 0.9999)) # type: ignore
147-
# This value-neutral fitting is only for passing unit tests.
152+
# This value-neutral fitting is only for passing unit tests, gets overridden
153+
# later by fit_transform().
148154
self.scaler.fit(np.array([[0.0], [0.9999]]))
149155

150156
def column_type(self) -> ColumnType:
@@ -156,7 +162,7 @@ def to_float(self, value: Value) -> float:
156162
return float((value - TIMESTAMP_REFERENCE) / pd.Timedelta(1, "s"))
157163

158164
def from_interval(self, interval: Interval, rng: Random) -> MicrodataValue:
159-
value = _generate_float(interval, rng)
165+
value = _generate_random_float(interval, rng)
160166
if self.value_safe_flag is True:
161167
value = _convert_to_safe_value(value, self.safe_values)
162168
assert self.scaler is not None
@@ -177,19 +183,30 @@ def __init__(self, values: Iterable[Value]) -> None:
177183
if not isinstance(value, str):
178184
raise TypeError(f"Not a `str` object in a string dtype column: {value}.")
179185
self.value_map = sorted(cast(Set[str], unique_values))
186+
180187
# Note that self.safe_values is only used if self.value_safe_flag is False
181-
self.safe_values: Set[int] = set()
188+
self.safe_values: Set[float] = set()
189+
# Fit up to 0.9999 so that the max bucket range is [0-1)
190+
self.scaler = MinMaxScaler(feature_range=(0.0, 0.9999)) # type: ignore
191+
# This value-neutral fitting is only for passing unit tests, gets overridden
192+
# later by fit_transform().
193+
self.scaler.fit(np.array([[0.0], [0.9999]]))
182194

183195
def column_type(self) -> ColumnType:
184196
return ColumnType.STRING
185197

186198
def to_float(self, value: Value) -> float:
199+
# Note that value here is the string itself, not an index.
187200
index = bisect_left(self.value_map, cast(str, value))
188201
assert index >= 0 and index < len(self.value_map)
189202
return float(index)
190203

191204
def from_interval(self, interval: Interval, rng: Random) -> MicrodataValue:
205+
assert self.scaler is not None
206+
interval = _find_encapsulated_integer_interval(interval, self.scaler)
207+
# From here on intervals are integers (cast as float)
192208
if interval.is_singularity():
209+
# convert to integer for value_map
193210
return (self.value_map[int(interval.min)], interval.min)
194211
else:
195212
return self._map_interval(interval, rng)
@@ -220,19 +237,32 @@ def analyze_tree_walk(node: Node) -> None:
220237
# Avoid the cost of maintaining safe_values if in any
221238
# event all values are safe (i.e. self.value_safe_flag is True)
222239
if self.value_safe_flag is False and node.is_singularity() and node.is_over_threshold(low_threshold):
223-
self.safe_values.add(int(node.actual_intervals[0].min))
240+
# Note that the values here are normalized
241+
self.safe_values.add(float(node.actual_intervals[0].min))
224242
elif isinstance(node, Branch):
225243
for child_node in node.children.values():
226244
analyze_tree_walk(child_node)
227245

228246
analyze_tree_walk(root)
247+
# from .tree import _dump_tree
248+
# _dump_tree(root) # Debugging line to see the tree structure
249+
250+
def denormalize_safe_values(self) -> None:
251+
assert self.scaler is not None
252+
if self.value_safe_flag is False and self.safe_values:
253+
# Convert normalized values back to original integer values
254+
denormalized_safe_values: Set[float] = set()
255+
for normalized_value in self.safe_values:
256+
original_value = _inverse_normalize_value(float(normalized_value), self.scaler)
257+
denormalized_safe_values.add(float(round(original_value)))
258+
self.safe_values = denormalized_safe_values
229259

230260
def create_value_safe_set(self, values: pd.Series) -> None:
231261
# Not needed
232262
pass
233263

234264

235-
def _generate_float(interval: Interval, rng: Random) -> float:
265+
def _generate_random_float(interval: Interval, rng: Random) -> float:
236266
return rng.uniform(interval.min, interval.max)
237267

238268

@@ -326,6 +356,66 @@ def _inverse_normalize_value(value: float, scaler: MinMaxScaler) -> float:
326356
return float(inverse_transformed_value)
327357

328358

359+
def _find_encapsulated_integer_interval(interval: Interval, scaler: MinMaxScaler) -> Interval:
360+
"""
361+
Find the largest interval within the given interval where the inverse-transformed
362+
bounds correspond to integers (within machine precision).
363+
364+
Args:
365+
interval: The input interval in normalized space
366+
scaler: The MinMaxScaler used for inverse transformation
367+
368+
Returns:
369+
A new interval with bounds that are integer values (cast as floats)
370+
"""
371+
interval_new = interval.copy()
372+
373+
# Handle singularity case - bounds are already at the same point
374+
if interval.is_singularity():
375+
# Convert the single value to its corresponding integer
376+
inverse_value = _inverse_normalize_value(interval.min, scaler)
377+
integer_value = float(round(inverse_value))
378+
interval_new.min = integer_value
379+
interval_new.max = integer_value
380+
return interval_new
381+
382+
# Find the smallest integer >= the inverse-transformed interval.min
383+
min_inverse = _inverse_normalize_value(interval.min, scaler)
384+
min_integer = int(round(min_inverse))
385+
386+
# If the current min already transforms to an integer (within precision), use it
387+
if abs(min_inverse - min_integer) < 1e-10:
388+
interval_new.min = float(min_integer)
389+
else:
390+
# Find the next integer
391+
next_integer = min_integer + 1 if min_inverse > min_integer else min_integer
392+
interval_new.min = float(next_integer)
393+
394+
# Find the largest integer <= the inverse-transformed interval.max
395+
max_inverse = _inverse_normalize_value(interval.max, scaler)
396+
max_integer = int(round(max_inverse))
397+
398+
# Note that the max value of an Interval is exclusive, so we need to take care
399+
if abs(max_inverse - max_integer) < 1e-10:
400+
# If this is exact, then it will be included in the next higher min_integer
401+
interval_new.max = float(max_integer)
402+
else:
403+
# Find the previous integer
404+
prev_integer = max_integer - 1 if max_inverse < max_integer else max_integer
405+
# We add 1.0 because the max value is exclusive
406+
interval_new.max = float(prev_integer + 1.0)
407+
408+
# Ensure the new interval is valid (min <= max)
409+
if interval_new.min > interval_new.max:
410+
# If no valid integer interval exists within bounds, throw an exception
411+
raise ValueError(
412+
f"No valid integer interval exists within bounds. "
413+
f"Min integer: {interval_new.min}, Max integer: {interval_new.max}"
414+
)
415+
416+
return interval_new
417+
418+
329419
def _normalize(values: pd.Series, scaler: Optional[MinMaxScaler]) -> pd.Series:
330420
if scaler is None:
331421
# Convertors that don't need normalization
@@ -370,6 +460,9 @@ def apply_convertors(convertors: list[DataConvertor], raw_data: pd.DataFrame) ->
370460
def generate_microdata(
371461
buckets: Buckets, convertors: list[DataConvertor], null_mappings: list[float], rng: Random
372462
) -> list[MicrodataRow]:
463+
# print(buckets) # Debugging line to see the buckets
464+
for convertor in convertors:
465+
convertor.denormalize_safe_values()
373466
microdata_rows: list[MicrodataRow] = []
374467
for bucket in buckets:
375468
microdata_rows.extend(
@@ -415,4 +508,3 @@ def make_value_safe_columns_array(df: pd.DataFrame, value_safe_columns: list[int
415508
result[column] = True
416509

417510
return result
418-
return result

syndiffix/tree.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,13 @@ def push_down_1dim_root(self) -> Node:
174174
def _matching_rows(self) -> Iterator[RowId]:
175175
yield from self.rows
176176

177+
def print(self) -> None:
178+
print("Leaf Node:")
179+
print(f" actual_intervals: {self.actual_intervals}")
180+
print(f" snapped_intervals: {self.snapped_intervals}")
181+
print(f" _noisy_count_cache: {self._noisy_count_cache}")
182+
print(f" rows: {self.rows}")
183+
177184

178185
class Branch(Node):
179186
def __init__(self, leaf: Leaf):
@@ -262,3 +269,32 @@ def push_down_1dim_root(self) -> Node:
262269
def _matching_rows(self) -> Iterator[RowId]:
263270
for child in self.children.values():
264271
yield from child._matching_rows()
272+
273+
def print(self) -> None:
274+
print("Branch Node:")
275+
print(f" actual_intervals: {self.actual_intervals}")
276+
print(f" snapped_intervals: {self.snapped_intervals}")
277+
print(f" _noisy_count_cache: {self._noisy_count_cache}")
278+
279+
280+
def _dump_tree(node: Node, indent: int = 0) -> None:
281+
"""Display the tree structure with directory-like indentation."""
282+
indent_str = " " * indent
283+
284+
# Format snapped_interval as [(min, max), (min, max), ...]
285+
intervals_str = ", ".join(f"({interval.min}, {interval.max})" for interval in node.snapped_intervals)
286+
287+
# Get row count
288+
if isinstance(node, Leaf):
289+
row_count = len(node.rows)
290+
else: # Branch
291+
row_count = len(list(node._matching_rows()))
292+
293+
# Print this node's info
294+
print(f"{indent_str}[{intervals_str}] rows: {row_count}")
295+
296+
# Recursively print children if this is a Branch
297+
if isinstance(node, Branch):
298+
for child_index in sorted(node.children.keys()):
299+
child = node.children[child_index]
300+
_dump_tree(child, indent + 1)

tests/data/tree.0_1_2.json

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
],
1111
[
1212
0.0,
13-
2.0
13+
1.0
1414
]
1515
],
1616
"count": 32,
@@ -27,7 +27,7 @@
2727
],
2828
[
2929
0.0,
30-
1.0
30+
0.5
3131
]
3232
],
3333
"count": 4,
@@ -44,8 +44,8 @@
4444
0.5
4545
],
4646
[
47-
1.0,
48-
2.0
47+
0.5,
48+
1.0
4949
]
5050
],
5151
"count": 4,
@@ -63,7 +63,7 @@
6363
],
6464
[
6565
0.0,
66-
1.0
66+
0.5
6767
]
6868
],
6969
"count": 4,
@@ -80,8 +80,8 @@
8080
1.0
8181
],
8282
[
83-
1.0,
84-
2.0
83+
0.5,
84+
1.0
8585
]
8686
],
8787
"count": 4,
@@ -99,7 +99,7 @@
9999
],
100100
[
101101
0.0,
102-
1.0
102+
0.5
103103
]
104104
],
105105
"count": 4,
@@ -116,8 +116,8 @@
116116
0.5
117117
],
118118
[
119-
1.0,
120-
2.0
119+
0.5,
120+
1.0
121121
]
122122
],
123123
"count": 4,
@@ -135,7 +135,7 @@
135135
],
136136
[
137137
0.0,
138-
1.0
138+
0.5
139139
]
140140
],
141141
"count": 4,
@@ -152,8 +152,8 @@
152152
1.0
153153
],
154154
[
155-
1.0,
156-
2.0
155+
0.5,
156+
1.0
157157
]
158158
],
159159
"count": 4,

tests/data/tree.2.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
{
2-
"ranges": [[0.0, 2.0]],
2+
"ranges": [[0.0, 1.0]],
33
"count": 32,
44
"children": {
55
"0": {
6-
"ranges": [[0.0, 1.0]],
6+
"ranges": [[0.0, 0.5]],
77
"count": 16,
88
"children": null
99
},
1010
"1": {
11-
"ranges": [[1.0, 2.0]],
11+
"ranges": [[0.5, 1.0]],
1212
"count": 16,
1313
"children": null
1414
}

tests/test_microdata.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ def test_casts_data_from_csv() -> None:
9797
"a": [0.0, 0.0],
9898
"b": [0.0, 0.0],
9999
"c": [0.0, 0.0],
100-
"d": [0.0, 1.0],
100+
"d": [0.0, 0.9999],
101101
"e": [np.nan, 0.0],
102102
"f": [np.nan, 0.0],
103103
"g": [np.nan, np.nan],

0 commit comments

Comments
 (0)