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 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,2 @@
1
+ from .CtrlRound import ctrl_round
2
+ from .generate_table import generate_random_table
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.41.3)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ CtrlRound