-
Notifications
You must be signed in to change notification settings - Fork 25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
New advanced example: CPMpyXplain algorithm #253
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,198 @@ | ||
""" | ||
Finding counterfactual explanation through constraint relaxation. | ||
|
||
Based on the "CounterFactualXplain"-algorithm of Dev Gupta, S., Genç, B., O'Sullivan, B. (2022, 7 april). Finding Counterfactual | ||
Explanations Through Constraint Relaxation. arXiv:2204.03429v1. | ||
|
||
An adapted algorithm which finds counterfactual explanations of the laptop problem presented in the paper. | ||
|
||
Use cases (changes possible with enable_iteration variable): | ||
- Default: One explanation, the order in which the constraints are considered is the same order as in which the user_constraints are mentioned in the 'user_constraints'-variable | ||
- An explanation for every possible order in which the constraints can be considered | ||
""" | ||
from cpmpy import * | ||
import itertools | ||
|
||
## This variable is responsible for the use cases | ||
enable_iteration = False | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Better name could be "enumeration" imho |
||
|
||
""" | ||
List of all the laptops. | ||
Format: [Size, Memory, Life, Price] | ||
All numbers must be multiplied by 100 since CPMpy can only work with Integer values. When creating the explanations, the values are automatically divided by 100. | ||
""" | ||
laptops = [[1540, 102400, 220, 149999], # Unsatisfiable, relax life time to get a satisfiable result | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe overkill but you can also structure this using a Pandas DataFrame, it might clean up the code later as you can simply index the columns you want using the name of the attribute of the laptop |
||
[1500, 51200, 1000, 261699], | ||
[1500, 51200, 450, 189900], | ||
[1400, 51200, 1000, 189999] | ||
] | ||
|
||
size = intvar(0, 2000) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also give the name of the variable |
||
memory = intvar(0, 102500) | ||
life = intvar(0, 1100) | ||
price = intvar(0, 300000) | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do you leave out the "make" constraint? The original paper has 4 attributes for each laptop: You can encode it as an integer variable too with value 0 = Lenovo, value 1 = HP and value 2 = Sony for example |
||
global size_cons | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please do not use global variables, use them as arguments to your functions! |
||
global memory_cons | ||
global life_cons | ||
# The foreground/user constraints: | ||
size_cons = size >= 1500 | ||
memory_cons = memory >= 51200 | ||
life_cons = life >= 1000 | ||
|
||
user_constraints = [size_cons, memory_cons, life_cons] | ||
|
||
# Information about the user constraints (usefull for creating explanations) | ||
user_constraint_names = ["size", "memory", "lifetime"] | ||
user_constraint_values = [1500, 51200, 1000] | ||
|
||
global new_constraint_values | ||
new_constraint_values = [0, 0, 0] | ||
|
||
# The background constraints (must be fullfilled at all time) | ||
price_cons = price <= 200000 | ||
|
||
background_constraints = [price_cons] | ||
|
||
# The relaxation spaces of all the user constraints | ||
size_relax = [1540,1500,1400,1110] | ||
mem_relax = [102400,51200] | ||
life_relax = [1100,1000,450,220] | ||
|
||
relaxation_spaces = [size_relax, mem_relax, life_relax] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use a dictionary instead with as key the variable and value the relaxed values: relaxation_spaces = {
size : [1540,1500,1400,1110],
memory: [102400,51200],
life: [1100,1000,450,220]
} |
||
|
||
# A table constraint model with all the foreground and background constraints | ||
model = Model( | ||
Table([size, memory, life, price], laptops), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice use of the table constraint! |
||
size_cons, | ||
memory_cons, | ||
life_cons, | ||
price_cons | ||
) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Everything from line 19 to here should go under the |
||
|
||
def main(): | ||
sat = model.solve() | ||
if sat: | ||
print(f"There exists a solution. The following laptop satisfies the constraints: size: {size.value()/100} inches, memory: {memory.value()/100} MB, life: {life.value()/100} hr, price: $ {price.value()/100}") | ||
elif no_sufficient_relax_space(): | ||
print("The defined relaxation spaces are not large enough. It is not possible to relax a constraint") | ||
else: | ||
# For explanation about the enable_iteration: Check the intro and enable_iteration variable on line 17 | ||
if enable_iteration: | ||
# Check the influence of a different order of constraint relaxations | ||
|
||
# save the old user constraint values and user constraints | ||
old_constraint_values = user_constraint_values.copy() | ||
old_user_constraints = user_constraints.copy() | ||
|
||
# Create permutations of the possible indices of the user constraints | ||
order_indices = list(range(0, len(user_constraints))) | ||
orders = list(itertools.permutations(order_indices)) | ||
|
||
for nb_explanation, order in enumerate(orders): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Performance suggestion: for nb_expl, order in enumerate(permutations(order_indices)): |
||
# Reset the constraints back to their original values | ||
reset_state(old_constraint_values, old_user_constraints) | ||
|
||
explanation = relax_problem(order) | ||
print(f"Explanation {nb_explanation}:") | ||
print(" " + explanation) | ||
else: | ||
explanation = relax_problem(list(range(0, len(user_constraints)))) | ||
print(explanation) | ||
|
||
|
||
def no_sufficient_relax_space(): | ||
for space in relaxation_spaces: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. return all(len(space) > 1 for space in relaxation_spaces)) |
||
if len(space) > 1: | ||
return False | ||
return True | ||
|
||
|
||
def reset_state(old_constraint_values, old_user_constraints): | ||
""" | ||
Reset the initial states of the constraints. | ||
""" | ||
user_constraint_values[:] = old_constraint_values | ||
user_constraints[:] = old_user_constraints | ||
|
||
|
||
def relax_problem(order): | ||
# Create a new model step by step until we find a satisfiable result (bottom-up) | ||
new_model = Model(Table([size, memory, life, price], laptops)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually I would consider the Table constraint also to be a background constraint |
||
|
||
# Add the background constraints to the model, these constraints must be fullfilled at all cost | ||
for background_con in background_constraints: | ||
new_model += background_con | ||
|
||
# Save the old values of the constraints (necessary for the explanations) | ||
old_constraint_values = user_constraint_values.copy() | ||
|
||
indices_changed_constraints = list() | ||
i = 0 | ||
while i < len(user_constraints): | ||
index = order[i] # We investigate different orders of constraints, so i can be different than order[i] | ||
|
||
user_con = user_constraints[index] | ||
|
||
test_model = new_model.copy() + user_con | ||
sat = test_model.solve() | ||
if sat: | ||
# The constraint don't have to be relaxed anymore, so add it to the new model | ||
new_model += user_con | ||
i += 1 # Check the next user constraint | ||
else: | ||
indices_changed_constraints.append(index) | ||
if (relaxation_spaces[index][relaxation_spaces[index].index(user_constraint_values[index])+1] != relaxation_spaces[index][-1]): | ||
# Check if the constraint is not the last element from the relaxation space | ||
# Otherwise you have to relax another element at the same time to find a solution | ||
relax_constraint(index) | ||
else: | ||
relax_constraint(index) | ||
new_model += user_con | ||
i += 1 | ||
|
||
indices = remove_duplicates(indices_changed_constraints) | ||
|
||
if new_model.solve(): | ||
return generate_explanation(old_constraint_values, indices) | ||
else: | ||
return "The model cannot be made feasible with these relaxation spaces" | ||
|
||
|
||
def remove_duplicates(indices): | ||
""" | ||
Removes the duplicates from the given list | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it not easier converting to a set? |
||
""" | ||
result = [] | ||
for index in indices: | ||
if index not in result: | ||
result.append(index) | ||
return result | ||
|
||
|
||
def relax_constraint(index): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you pass as argument |
||
""" | ||
Generate the constraint to the next value in the relaxation space | ||
""" | ||
new_constraint_values[index] = relaxation_spaces[index][relaxation_spaces[index].index(user_constraint_values[index])+1] | ||
user_constraint_values[index] = new_constraint_values[index] | ||
if user_constraints[index].name == ">=": | ||
user_constraints[index] = (user_constraints[index].args)[0] >= new_constraint_values[index] | ||
elif user_constraints[index].name == "<=": | ||
user_constraints[index] = (user_constraints[index].args)[0] <= new_constraint_values[index] | ||
else: | ||
user_constraints[index] = (user_constraints[index].args)[0] == new_constraint_values[index] | ||
|
||
|
||
def generate_explanation(old_constraint_values, indices): | ||
explanation = f"There are {len(indices)} constraints that have to be relaxed to find at least one solution:\n" | ||
for index in indices: | ||
old_cons = old_constraint_values[index]/100 | ||
new_cons = user_constraint_values[index]/100 | ||
cons_name = user_constraint_names[index] | ||
explanation += f" Change the {cons_name} user constraint from {old_cons} to {new_cons}\n" | ||
return explanation | ||
|
||
|
||
if __name__ == "__main__": | ||
main() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use
import cpmpy as cp
instead