CtrlRound 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- CtrlRound/CtrlRound.py +213 -0
- CtrlRound/__init__.py +2 -0
- CtrlRound/best_first_search.py +75 -0
- CtrlRound/distance.py +38 -0
- CtrlRound/generate_table.py +14 -0
- CtrlRound-0.1.0.dist-info/LICENSE +21 -0
- CtrlRound-0.1.0.dist-info/METADATA +79 -0
- CtrlRound-0.1.0.dist-info/RECORD +10 -0
- CtrlRound-0.1.0.dist-info/WHEEL +5 -0
- CtrlRound-0.1.0.dist-info/top_level.txt +1 -0
CtrlRound/CtrlRound.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
from itertools import combinations
|
|
3
|
+
import functools
|
|
4
|
+
from time import perf_counter
|
|
5
|
+
from .best_first_search import best_first_search
|
|
6
|
+
from .distance import define_margin_distance, define_interior_distance
|
|
7
|
+
|
|
8
|
+
def agg_by(df:pd.DataFrame, by, var, id):
|
|
9
|
+
#aggregate a grouped dataframe
|
|
10
|
+
if by is None or not by:
|
|
11
|
+
sum_value = df[var].sum()
|
|
12
|
+
contributing_rows = list(df[id])
|
|
13
|
+
df_agg = pd.DataFrame({
|
|
14
|
+
var: [sum_value],
|
|
15
|
+
id: [contributing_rows]
|
|
16
|
+
})
|
|
17
|
+
else:
|
|
18
|
+
df_agg = df.groupby(by).agg({
|
|
19
|
+
var: "sum",
|
|
20
|
+
id: lambda x: x.tolist()
|
|
21
|
+
}).reset_index()
|
|
22
|
+
return df_agg
|
|
23
|
+
|
|
24
|
+
def aggregate_and_list(df:pd.DataFrame, by, var=None, margins=None, id=None):
|
|
25
|
+
if by is not None and not isinstance(by,list):
|
|
26
|
+
by = [by]
|
|
27
|
+
|
|
28
|
+
subsets=[]
|
|
29
|
+
if by is not None:
|
|
30
|
+
for i in range(0,len(by)):
|
|
31
|
+
comb = combinations(by,i)
|
|
32
|
+
subsets = subsets + [list(c) for c in comb]
|
|
33
|
+
else:
|
|
34
|
+
subsets=[[]]
|
|
35
|
+
|
|
36
|
+
if margins is not None:
|
|
37
|
+
subsets = [sub for sub in subsets if sub in margins]
|
|
38
|
+
|
|
39
|
+
df_out = pd.DataFrame()
|
|
40
|
+
for sub in subsets:
|
|
41
|
+
sub_agg = agg_by(df, by=sub, var=var, id=id)
|
|
42
|
+
df_out = pd.concat([df_out,sub_agg],ignore_index=True)
|
|
43
|
+
return df_out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_unique_col_name(df, base_name):
|
|
47
|
+
# Generate a unique column name
|
|
48
|
+
i = 1
|
|
49
|
+
new_name = base_name
|
|
50
|
+
while new_name in df.columns:
|
|
51
|
+
new_name = f"{base_name}_{i}"
|
|
52
|
+
i += 1
|
|
53
|
+
return new_name
|
|
54
|
+
|
|
55
|
+
def timer(func):
|
|
56
|
+
@functools.wraps(func)
|
|
57
|
+
def wrapper_timer(*args, **kwargs):
|
|
58
|
+
tic = perf_counter()
|
|
59
|
+
value = func(*args, **kwargs)
|
|
60
|
+
toc = perf_counter()
|
|
61
|
+
elapsed_time = toc - tic
|
|
62
|
+
print(f"Elapsed time: {elapsed_time:0.4f} seconds")
|
|
63
|
+
return value
|
|
64
|
+
return wrapper_timer
|
|
65
|
+
|
|
66
|
+
@timer
|
|
67
|
+
def ctrl_round(df_in, by, var, margins=None, rounding_base=1, fix_rounding_dist= 0, max_heap_size= 1000):
|
|
68
|
+
"""
|
|
69
|
+
Aggregate a dataframe and perform controlled rounding of it's entries.
|
|
70
|
+
input:
|
|
71
|
+
df_in : pandas dataframe
|
|
72
|
+
by : list of column names on which to aggregate the input dataframe
|
|
73
|
+
margins : list of list of column name indicating which grouping to aggregate. Can be empty in which case all grouping and subgrouping are aggregated.
|
|
74
|
+
Controlling the rounding on a subset of margins will improve the run-time but will leave the other margins free to potentialy deviate far from their original values.
|
|
75
|
+
var : column to be aggregated
|
|
76
|
+
rounding_base : the rounding base. Has to be greater than 0.
|
|
77
|
+
fix_rounding_dist : if an entry is close to a rounded value by p% of the rounding base, round that entry to it's closest rounded value and remove the other rounded value from consideration for that entry.
|
|
78
|
+
This reduces the serach space and run time at the cost of the quality of the solution.
|
|
79
|
+
max_heap_size : the maximum size the heap. Has to be greater than 2. Default is 1000.
|
|
80
|
+
A smaller heap will lead to faster run-time at the cost of the quality of the solution.
|
|
81
|
+
|
|
82
|
+
output:
|
|
83
|
+
A dictionary with following keys:
|
|
84
|
+
input_table : the original input data with columns listed in the "by" and "var" input parameters.
|
|
85
|
+
input_margins :
|
|
86
|
+
rounded_table : the rounded solution of input data with columns listed in the "by" and "var" input parameters.
|
|
87
|
+
rounded_margins :
|
|
88
|
+
objectives : the objective functions value for the solution
|
|
89
|
+
opt_report : a dictionary containing information about the optimisation process with folowing keys:
|
|
90
|
+
n_iterations : the number of partial solution expanded
|
|
91
|
+
n_heap_purges : the number of times the heap was purged, keeping the best solutions so far
|
|
92
|
+
n_sol_purged : the total number of partial solution that got purged and never further expanded
|
|
93
|
+
n_cells : the number of entries in the input table
|
|
94
|
+
n_margins : the number of margin values from the input table
|
|
95
|
+
n_fixed_cells : the number of cells where the rounding is fixed and not subject to the optimisation process
|
|
96
|
+
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
# check input parameters
|
|
100
|
+
if rounding_base <= 0:
|
|
101
|
+
print("Error: rounding_base has to be greater than 0")
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
if max_heap_size < 2:
|
|
105
|
+
print("Error: max_heap_size has to be greater than 1")
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
if fix_rounding_dist < 0:
|
|
109
|
+
print("Error: fix_rounding_dist has to be greater than or equal to 0")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
if fix_rounding_dist >= 0.5:
|
|
113
|
+
print("Error: fix_rounding_dist has to be less than 0.5")
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
n_cells = 0
|
|
117
|
+
n_margins = 0
|
|
118
|
+
n_fixed_cells = 0
|
|
119
|
+
|
|
120
|
+
# aggregate "var" by "by" columns in case there are duplicates in the input to make sure we have a table with signle entries per cell
|
|
121
|
+
by_values = df_in.groupby(by).sum(var).reset_index()
|
|
122
|
+
# get a unique name not already present in the dataframe to store cell identifier
|
|
123
|
+
cell_id_name = get_unique_col_name(by_values,"cellId")
|
|
124
|
+
# create a unique identifer for each cell of the table
|
|
125
|
+
by_values[cell_id_name] = range(len(by_values))
|
|
126
|
+
cellId_lst = list(by_values[cell_id_name])
|
|
127
|
+
n_cells = len(cellId_lst)
|
|
128
|
+
|
|
129
|
+
# create a mapping of each cell identifer to each value from the table
|
|
130
|
+
var_values = by_values[[cell_id_name,var]].copy()
|
|
131
|
+
initial_values = {}
|
|
132
|
+
for index, row in var_values.iterrows():
|
|
133
|
+
initial_values[row[cell_id_name]] = row[var]
|
|
134
|
+
|
|
135
|
+
# create a mapping of each cell identifer to each possible rounded value
|
|
136
|
+
possible_values = var_values
|
|
137
|
+
lower_residual = possible_values[var] % rounding_base
|
|
138
|
+
lower = get_unique_col_name(by_values,"lower")
|
|
139
|
+
upper = get_unique_col_name(by_values,"upper")
|
|
140
|
+
residual = get_unique_col_name(by_values,"residual")
|
|
141
|
+
|
|
142
|
+
possible_values[lower] = possible_values[var] - lower_residual
|
|
143
|
+
possible_values[upper] = possible_values[lower] + rounding_base
|
|
144
|
+
possible_values[residual] = lower_residual
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# check if the original value is not already rounded, in which case the upper value should be the same.
|
|
148
|
+
possible_cell_values = {cellId:[] for cellId in cellId_lst}
|
|
149
|
+
for index, row in possible_values[[cell_id_name, lower, upper, residual]].iterrows():
|
|
150
|
+
# if upper is the same as lower, generate only one possibility
|
|
151
|
+
if row[residual] <= fix_rounding_dist * rounding_base:
|
|
152
|
+
possible_cell_values[row[cell_id_name]] = [row[lower]]
|
|
153
|
+
n_fixed_cells += 1
|
|
154
|
+
elif row[residual] > (1-fix_rounding_dist) * rounding_base:
|
|
155
|
+
possible_cell_values[row[cell_id_name]] = [row[upper]]
|
|
156
|
+
n_fixed_cells += 1
|
|
157
|
+
else:
|
|
158
|
+
possible_cell_values[row[cell_id_name]] = [row[lower], row[upper]]
|
|
159
|
+
|
|
160
|
+
# get margins of the input table
|
|
161
|
+
df_margins = aggregate_and_list(by_values, by, var, margins, cell_id_name)
|
|
162
|
+
cons_id_name = get_unique_col_name(df_margins,"consId")
|
|
163
|
+
df_margins[cons_id_name] = range(len(df_margins))
|
|
164
|
+
n_margins = len(df_margins)
|
|
165
|
+
|
|
166
|
+
# create a mapping of each margin identifer to each aggregated value
|
|
167
|
+
constraint_values = {}
|
|
168
|
+
for index, row in df_margins[[cons_id_name, var]].iterrows():
|
|
169
|
+
constraint_values[row[cons_id_name]] = row[var]
|
|
170
|
+
|
|
171
|
+
# create a mapping of each margin identifer to a list of each cell identifer adding up to it
|
|
172
|
+
constraints = {}
|
|
173
|
+
for index, row in df_margins[[cons_id_name,cell_id_name]].iterrows():
|
|
174
|
+
constraints[row[cons_id_name]] = row[cell_id_name]
|
|
175
|
+
|
|
176
|
+
# define out distances measures
|
|
177
|
+
calculate_margin_max_distance = define_margin_distance(max, normalized=False)
|
|
178
|
+
calculate_margin_sum_distance = define_margin_distance(sum)
|
|
179
|
+
calculate_interior_sum_distance = define_interior_distance(sum)
|
|
180
|
+
distanceFuncs = [calculate_margin_max_distance, calculate_margin_sum_distance, calculate_interior_sum_distance]
|
|
181
|
+
# obtain the best rounding
|
|
182
|
+
result, n_iterations, n_heap_purges, n_sol_purged = best_first_search(possible_cell_values, initial_values, constraints, constraint_values, distanceFuncs, n_solutions = 1, max_heap_size= max_heap_size )
|
|
183
|
+
solution = result[0][-1]
|
|
184
|
+
objectives = result[0][:-1]
|
|
185
|
+
# assign the rounded values into a dataframe ready for output
|
|
186
|
+
df_out = by_values.copy()
|
|
187
|
+
|
|
188
|
+
df_out[var] = by_values[cell_id_name].map(solution)
|
|
189
|
+
margins = aggregate_and_list(df_out, by, var, margins, cell_id_name)
|
|
190
|
+
margins = margins[[*by,var]]
|
|
191
|
+
df_out = df_out.drop(cell_id_name,axis=1)
|
|
192
|
+
|
|
193
|
+
by_values = by_values.drop(cell_id_name,axis=1)
|
|
194
|
+
df_margins = df_margins.drop(cell_id_name,axis=1)
|
|
195
|
+
df_margins = df_margins.drop(cons_id_name,axis=1)
|
|
196
|
+
|
|
197
|
+
# report information from the optimization process
|
|
198
|
+
opt_report = {}
|
|
199
|
+
opt_report["n_iterations"] = n_iterations
|
|
200
|
+
opt_report["n_heap_purges"] = n_heap_purges
|
|
201
|
+
opt_report["n_sol_purged"] = n_sol_purged
|
|
202
|
+
|
|
203
|
+
output = {"input_table" : by_values, \
|
|
204
|
+
"input_margins" : df_margins, \
|
|
205
|
+
"rounded_table" : df_out, \
|
|
206
|
+
"rounded_margins" : margins, \
|
|
207
|
+
"objectives" : objectives, \
|
|
208
|
+
"opt_report" : opt_report, \
|
|
209
|
+
"n_cells" : n_cells, \
|
|
210
|
+
"n_margins" : n_margins, \
|
|
211
|
+
"n_fixed_cells" : n_fixed_cells}
|
|
212
|
+
return output
|
|
213
|
+
|
CtrlRound/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import heapq
|
|
2
|
+
|
|
3
|
+
def best_first_search(possible_cell_values, initial_values, constraints, constraint_values, distance_funcs, n_solutions=0, max_heap_size=1000, reset_heap_fraction=0.75):
|
|
4
|
+
"""
|
|
5
|
+
Performs best first search
|
|
6
|
+
input:
|
|
7
|
+
possible_cell_values : dictionary of all decision variables along of all possible values for each
|
|
8
|
+
initial_values : dictionary of initial values for each decision variable
|
|
9
|
+
constraints : dictionary of each contraints to a list of decison variables that aggregate to that constraint's value
|
|
10
|
+
constraint_values : dictionary of each contraints to the value they shopudl aggregate to
|
|
11
|
+
n_solutions : the number of solutions to output. The first solutions found.
|
|
12
|
+
distance_funcs : list of functions that will be used to calculation a lsit of distances to associate with a current (partial) solution
|
|
13
|
+
max_heap_size : the maximum size the heap can be. If reached, half the best solutions will be kept.
|
|
14
|
+
reset_heap_fraction : When the heap reaches it's maximum size, it is trimmed to keep only the most promising solution. This parameter determines the size of the heap after being trimmed as a fraction of the maximum size.
|
|
15
|
+
This parameter has to be between 0 and 1. The higher the value, the more often heap timming occurs. Each trim inceases run-time.
|
|
16
|
+
"""
|
|
17
|
+
# a unique counter for each partial solution pushed in the heap
|
|
18
|
+
counter = 0
|
|
19
|
+
n_heap_purges = 0
|
|
20
|
+
n_sol_purged = 0
|
|
21
|
+
|
|
22
|
+
# number of distance functions passed
|
|
23
|
+
nfuncs = len(distance_funcs)
|
|
24
|
+
# the size of the heap after trimming
|
|
25
|
+
reset_heap_size = int(reset_heap_fraction * max_heap_size)
|
|
26
|
+
|
|
27
|
+
# Priority queue for Best First Search
|
|
28
|
+
pq = []
|
|
29
|
+
|
|
30
|
+
#the first solution is the one where no decision has been made yet
|
|
31
|
+
initial_partial_solution = {}
|
|
32
|
+
param_list = [initial_partial_solution,initial_values,constraints,constraint_values]
|
|
33
|
+
initial_distances = [f(*param_list) for f in distance_funcs]
|
|
34
|
+
initial_state = (*initial_distances, counter, initial_partial_solution)
|
|
35
|
+
|
|
36
|
+
heapq.heappush(pq, initial_state)
|
|
37
|
+
|
|
38
|
+
Solutions = []
|
|
39
|
+
while pq:
|
|
40
|
+
current_total_distance, current_margin_distance, current_interior_distance, current_counter, current_partial_solution = heapq.heappop(pq)
|
|
41
|
+
|
|
42
|
+
if len(current_partial_solution) == len(initial_values):
|
|
43
|
+
Solutions.append((current_total_distance,current_margin_distance,current_interior_distance,current_partial_solution))
|
|
44
|
+
|
|
45
|
+
# output the N first Solutions found
|
|
46
|
+
if n_solutions > 0 and len(Solutions) == n_solutions:
|
|
47
|
+
return Solutions, counter, n_heap_purges, n_sol_purged
|
|
48
|
+
|
|
49
|
+
# Generate neighbors
|
|
50
|
+
for cell_id in possible_cell_values:
|
|
51
|
+
if cell_id not in current_partial_solution:
|
|
52
|
+
for value in possible_cell_values[cell_id]:
|
|
53
|
+
new_partial_solution = current_partial_solution.copy()
|
|
54
|
+
new_partial_solution[cell_id] = value
|
|
55
|
+
|
|
56
|
+
new_param_list = [new_partial_solution,initial_values,constraints,constraint_values]
|
|
57
|
+
new_distances = [f(*new_param_list) for f in distance_funcs]
|
|
58
|
+
|
|
59
|
+
# a unique counter is stored in the state so that the heap will never attempt at comparing partial soutions distionaries as this would result in an error
|
|
60
|
+
# if both distances are the same as another element in the heap, at least the counter will be different and used to order the elements
|
|
61
|
+
counter += 1
|
|
62
|
+
new_state = (*new_distances, counter, new_partial_solution)
|
|
63
|
+
heapq.heappush(pq,new_state)
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
#if heap gets too large, cut it in half keeping only the best partial solutions
|
|
67
|
+
if len(pq) > max_heap_size:
|
|
68
|
+
pq.sort(key=lambda x: x[:nfuncs])
|
|
69
|
+
n_sol_purged += len(pq) - reset_heap_size
|
|
70
|
+
pq = pq[:reset_heap_size]
|
|
71
|
+
heapq.heapify(pq)
|
|
72
|
+
n_heap_purges += 1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
return Solutions, counter, n_heap_purges, n_sol_purged
|
CtrlRound/distance.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#all distance functions must acces input parameters partial_solution, initial_values, constraints, constraint_values
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def define_margin_distance(func, normalized=True):
|
|
5
|
+
#define the distance function used through the aggregation function used on the margins
|
|
6
|
+
def calculate_margin_distance(partial_solution, initial_values, constraints, constraint_values):
|
|
7
|
+
# merge the solution so far with initial values for the cells where no decision has been taken yet
|
|
8
|
+
current_values = initial_values.copy()
|
|
9
|
+
for cell,val in partial_solution.items():
|
|
10
|
+
current_values[cell]=val
|
|
11
|
+
|
|
12
|
+
nCell = len(partial_solution)
|
|
13
|
+
marginDiscrepancies = []
|
|
14
|
+
for cons in constraints:
|
|
15
|
+
target_value = constraint_values[cons]
|
|
16
|
+
current_value = sum(current_values[cell_id] for cell_id in constraints[cons])
|
|
17
|
+
marginDiscrepancies.append(abs(target_value - current_value))
|
|
18
|
+
# marginDiscrepancies = [abs(constraint_values[cons] - sum(current_values[cell_id] for cell_id in constraints[cons])) for cons in constraints]
|
|
19
|
+
if nCell >1 and normalized:
|
|
20
|
+
return func(marginDiscrepancies)/nCell
|
|
21
|
+
else:
|
|
22
|
+
return func(marginDiscrepancies)
|
|
23
|
+
return calculate_margin_distance
|
|
24
|
+
|
|
25
|
+
def define_interior_distance(func, normalized=True):
|
|
26
|
+
#define a distance function on the interior cells
|
|
27
|
+
def calculate_interior_distance(partial_solution, initial_values, constraints, constraint_values):
|
|
28
|
+
# calculate the deviation from the origianl values in the interior cells
|
|
29
|
+
# constraints and constraints values are not used but are input parameter to have a uniform interface across all distance functions
|
|
30
|
+
if len(partial_solution) == 0 :
|
|
31
|
+
return 0
|
|
32
|
+
nCell = len(partial_solution)
|
|
33
|
+
if nCell >1 and normalized:
|
|
34
|
+
return func(abs(partial_solution[cell] - initial_values[cell]) for cell in partial_solution)/nCell
|
|
35
|
+
else:
|
|
36
|
+
return func(abs(partial_solution[cell] - initial_values[cell]) for cell in partial_solution)
|
|
37
|
+
|
|
38
|
+
return calculate_interior_distance
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import itertools
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def generate_random_table(n_dim,n_cat,scale=1):
|
|
7
|
+
#generate n_dim columns each with n_cat values
|
|
8
|
+
sets = [set(range(n_cat)) for _ in range(n_dim)]
|
|
9
|
+
cartesian_product = list(itertools.product(*sets))
|
|
10
|
+
df = pd.DataFrame(cartesian_product, columns=[*range(n_dim)])
|
|
11
|
+
#generate random values between 0 and scale
|
|
12
|
+
df["value"] = np.random.rand(len(df)) * scale
|
|
13
|
+
return df
|
|
14
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Christian Gagné
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: CtrlRound
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Performs controlled rounding of tabular data
|
|
5
|
+
Home-page: https://github.com/Veozen/CtrlRound
|
|
6
|
+
Author: Christian Gagné
|
|
7
|
+
Author-email: christian.gagne@gmail.com
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: numpy >=1.23.5
|
|
15
|
+
Requires-Dist: pandas >=2.1.2
|
|
16
|
+
|
|
17
|
+
## Description
|
|
18
|
+
Controlled rounding is a technique used in data publishing to ensure that data released for public use meets specific confidentiality requirements.
|
|
19
|
+
The process adjusts the data values by rounding them to a predetermined base in a such a way that the margins of the resulting table remain closer to their original values than they would should the table's entries have been simply rounded to their nearest base.
|
|
20
|
+
|
|
21
|
+
Here the solution to this problem is found by applying a best-first-search method where the decision is to round up or down each non-margin entry. Three distance functions are used to sort partial solutions.
|
|
22
|
+
- The max absolute difference between a margin's value and it's original value.
|
|
23
|
+
- The average absolute difference between a margin's value and it's original value. The average is taken over the rounded cells so far.
|
|
24
|
+
- The average absolute difference between a table cell's rounded value and it's original value. The average is taken over the rounded cells so far.
|
|
25
|
+
|
|
26
|
+
Ties on the first function are resolved by looking at the second and then third one. The first complete solution found is returned.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
**ctrl_round(df_in, by, var, margins, rounding_base, fix_rounding_dist, max_heap_size):**
|
|
31
|
+
Aggregates a dataframe and perform controlled rounding of it's entries.
|
|
32
|
+
|
|
33
|
+
**input:**
|
|
34
|
+
- **df_in** : pandas DataFrame
|
|
35
|
+
- **by** : list of column names on which to aggregate the input DataFrame
|
|
36
|
+
- **margins** : list of lists of column names indicating which grouping to aggregate. Can be empty, in which case all grouping and subgrouping are aggregated. Controlling the rounding on a subset of margins will improve the run-time but will leave the other margins free to potentially deviate far from their original values.
|
|
37
|
+
- **var** : column to be aggregated
|
|
38
|
+
- **rounding_base** : the rounding base. Has to be greater than 0.
|
|
39
|
+
- **fix_rounding_dist** : if an entry is close to a rounded value by p% of the rounding base, round that entry to its closest rounded value and remove the other rounded value from consideration for that entry. This reduces the search space and run time at the cost of the quality of the solution.
|
|
40
|
+
- **max_heap_size** : the maximum size of the heap. Has to be greater than 2. Default is 1000. A smaller heap will lead to faster run-time at the cost of the quality of the solution.
|
|
41
|
+
|
|
42
|
+
**output:**
|
|
43
|
+
A dictionary with the following keys:
|
|
44
|
+
- **input_table** : the original input data with columns listed in the "by" and "var" input parameters.
|
|
45
|
+
- **input_margins** :
|
|
46
|
+
- **rounded_table** : the rounded solution of input data with columns listed in the "by" and "var" input parameters.
|
|
47
|
+
- **rounded_margins** :
|
|
48
|
+
- **objectives** : the objective function's value for the solution
|
|
49
|
+
- **opt_report** : a dictionary containing information about the optimisation process with the following keys:
|
|
50
|
+
- **n_iterations** : the number of partial solutions expanded
|
|
51
|
+
- **n_heap_purges** : the number of times the heap was purged, keeping the best solutions so far
|
|
52
|
+
- **n_sol_purged** : the total number of partial solutions that got purged and never further expanded
|
|
53
|
+
- **n_cells** : the number of entries in the input table
|
|
54
|
+
- **n_margins** : the number of margin values from the input table
|
|
55
|
+
- **n_fixed_cells** : the number of cells where the rounding is fixed and not subject to the optimisation process
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
**generate_random_table(n_dim,n_cat,scale):**
|
|
59
|
+
Creates a table filled with random values with the desired number of dimensions and number of categories per dimensions.
|
|
60
|
+
|
|
61
|
+
**input:**
|
|
62
|
+
- n_dim : number of dimensions
|
|
63
|
+
- n_cat : number of categories per dimensions
|
|
64
|
+
- scale : the scale of the numbers in the table: 0 < number < scale.
|
|
65
|
+
|
|
66
|
+
**output:**
|
|
67
|
+
The generated the table as a pandas dataframe. With columns:
|
|
68
|
+
- 0,1,2 ... n_dim-1 : each column contains values from 0 to n_cat-1.
|
|
69
|
+
- value : contains a random value between 0 and scale.
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
## Example
|
|
73
|
+
|
|
74
|
+
test = generate_random_table(3, 5, scale=2)
|
|
75
|
+
ctrl_round(test, by=[0,1,2], var="value", rounding_base=1, fix_rounding_dist=0.1, max_heap_size=100)
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
This project is licensed under the MIT License -
|
|
79
|
+
see the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
CtrlRound/CtrlRound.py,sha256=sBLQlZEePN1Gij00XBA8WFUzp9rp4z82rC6oZKaq-Z8,9521
|
|
2
|
+
CtrlRound/__init__.py,sha256=sGMdYdUi7IZQckK23ycKa6s0rkrPVbZGExqPx_Kk_gw,84
|
|
3
|
+
CtrlRound/best_first_search.py,sha256=bmbJ4v1aBEkyZlqTz0uK8PnCQsA5MEM2LyrwU7pPTnI,4359
|
|
4
|
+
CtrlRound/distance.py,sha256=Il6YnVivFOyN6D45ll3zFT6evMujadap_-4HC15oPJo,2084
|
|
5
|
+
CtrlRound/generate_table.py,sha256=zY52WpwtUjNtd_G-eVTmDYDdtY18QOCb5_h_y2kUNKs,444
|
|
6
|
+
CtrlRound-0.1.0.dist-info/LICENSE,sha256=Dup0VF41C5uKQdZ_CoUEnfBWmOpbMi2O1S3IoONvAx4,1092
|
|
7
|
+
CtrlRound-0.1.0.dist-info/METADATA,sha256=QM-ns1NR41bROp3bUT-wn3W9I4-ecxXsnVrbISmbDzE,4852
|
|
8
|
+
CtrlRound-0.1.0.dist-info/WHEEL,sha256=Xo9-1PvkuimrydujYJAjF7pCkriuXBpUPEjma1nZyJ0,92
|
|
9
|
+
CtrlRound-0.1.0.dist-info/top_level.txt,sha256=5UX_Ixt5hSuYvYzbMB64vE1aYPjHw2K8Yhm1OUprZj4,10
|
|
10
|
+
CtrlRound-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
CtrlRound
|