Package: cfnow
CFNOW - CounterFactual Nearest Optimal WololoDescriptionTL;DR: You just need adataset pointand amodel prediction function. CFNOW will find the closest point with a different class.The simplest way to generate counterfactuals for any tabular dataset and model.This package finds an optimal point (closer to the  input dataset point), which the classification is different from the original classification (i.e. "flips" the classification of the original input by minimally changin it).How to installpipinstallcfnowTable of ContentsMinimal exampleCounterfactual ChartsI have: binary categorical features!I have: one-hot encoded features!I have: binary and OHE features!Image CounterfactualsText CounterfactualsHow to citeRequirementsPython >= 3.8Minimal example:fromcfnowimportfind_tabularimportsklearn.datasetsimportsklearn.ensembleimportpandasaspd# Generating a sample modelX,y=sklearn.datasets.load_iris(return_X_y=True)model=sklearn.ensemble.RandomForestClassifier()model.fit(X,y)# Selecting a random pointx=X[0]# Here we can see the original classprint(f"Factual:{x}\nFactual class:{model.predict_proba([x])}")# Then, we use CFNOW to generate the minimum modification to change the classificationcf_obj=find_tabular(factual=pd.Series(x),model_predict_proba=model.predict_proba,limit_seconds=10)# Here we can see the new classprint(f"CF:{cf_obj.cfs[0]}\nCF class:{model.predict_proba([cf_obj.cfs[0]])}")Showing the Counterfactuals graphicallyThis package is integrated withCounterPlots, that allows you to graphically represent your counterfactual explanations!You can simply generate Greedy, CounterShapley, and Constellation charts for a given CF with:Greedy# Get the counterplots for the first CF and save as greedy.pngcf_obj.generate_counterplots(0).greedy('greedy.png')Output ExampleCounterShapley# Get the counterplots for the first CF and save as greedy.pngcf_obj.generate_counterplots(0).countershapley('countershapley.png')Constellation# Get the counterplots for the first CF and save as greedy.pngcf_obj.generate_counterplots(0).constellation('constellation.png')Improving your resultsThe minimal example above considers all features as numerical continuous, however, some datasets can have categorical (binary or one-hot encoded) features. CFNOW can handle these data types in a simple way as demonstrated below:I have binary categorical features!1 - Prepare the datasetimportpandasaspdimportnumpyasnpimportsklearn.ensemble# Generate data with 5 binary categorical features and 3 continuous numerical featuresX=np.hstack((np.random.randint(0,2,size=(1000,5)),np.random.rand(1000,3)*100))# Random binary target variabley=np.random.randint(0,2,1000)# Train RandomForestClassifiermodel=sklearn.ensemble.RandomForestClassifier().fit(X,y)# Display the original class for a random test samplex=X[0]print(f"Factual:{x}\nFactual class:{model.predict_proba([x])}")2 - Find the CFfromcfnowimportfind_tabular# Then, we use CFNOW to generate the minimum modification to change the classificationcf_obj=find_tabular(factual=pd.Series(x),feat_types={i:'cat'ifi<5else'cont'foriinrange(8)},model_predict_proba=model.predict_proba,limit_seconds=10)# Here we can see the new classprint(f"CF:{cf_obj.cfs[0]}\nCF class:{model.predict_proba([cf_obj.cfs[0]])}")I have one-hot encoded features!1 - Prepare the datasetimportwarningsimportpandasaspdimportnumpyasnpfromsklearn.preprocessingimportOneHotEncoderimportsklearn.ensemblewarnings.filterwarnings("ignore",message="X does not have valid feature names, but RandomForestClassifier was fitted with feature names")# Generate dataX=np.hstack((np.random.randint(0,10,size=(1000,5)),np.random.rand(1000,3)*100))# One-hot encode the first 5 categorical columns# !!!IMPORTANT!!! The naming of OHE encoding features columns MUST follow feature_value format.# Therefore, for a feature called color with value equal to red or blue, the OHE encoding columns# must be named color_red and color_blue. Otherwise, the CF will not be able to find the correct# columns to modify.encoder=OneHotEncoder(sparse=False)X_cat_encoded=encoder.fit_transform(X[:,:5])names=encoder.get_feature_names_out(['cat1','cat2','cat3','cat4','cat5'])# Combine and convert to DataFramedf=pd.DataFrame(np.hstack((X_cat_encoded,X[:,5:])),columns=list(names)+['num1','num2','num3'])# Random binary target variabley=np.random.randint(0,2,1000)# Train RandomForestClassifiermodel=sklearn.ensemble.RandomForestClassifier().fit(df,y)# Display the original class for a random test samplex=df.iloc[0]print(f"Factual:{x.tolist()}\nFactual class:{model.predict_proba([x])}")2 - Find the CFfromcfnowimportfind_tabular# Then, we use CFNOW to generate the minimum modification to change the classificationcf_obj=find_tabular(factual=x,feat_types={c:'cat'if'cat'incelse'cont'forcindf.columns},has_ohe=True,model_predict_proba=model.predict_proba,limit_seconds=10)# Here we can see the new classprint(f"CF:{cf_obj.cfs[0]}\nCF class:{model.predict_proba([cf_obj.cfs[0]])}")I have one-hot and binary categorical features!1 - Prepare the datasetimportwarningsimportpandasaspdimportnumpyasnpfromsklearn.preprocessingimportOneHotEncoderimportsklearn.ensemblewarnings.filterwarnings("ignore",message="X does not have valid feature names, but RandomForestClassifier was fitted with feature names")# Generate dataX=np.hstack((np.random.randint(0,10,size=(1000,5)),np.random.rand(1000,3)*100))# One-hot encode the first 5 categorical columns# !!!IMPORTANT!!! The naming of OHE encoding features columns MUST follow feature_value format.# Therefore, for a feature called color with value equal to red or blue, the OHE encoding columns# must be named color_red and color_blue. Otherwise, the CF will not be able to find the correct# columns to modify. For binary, it is just sufficient to name refer the column as cat.encoder=OneHotEncoder(sparse=False)X_cat_encoded=encoder.fit_transform(X[:,:5])names=encoder.get_feature_names_out(['cat1','cat2','cat3','cat4','cat5'])# Combine and convert to DataFramedf=pd.DataFrame(np.hstack((X_cat_encoded,X[:,5:])),columns=list(names)+['num1','num2','num3'])# Random binary target variabley=np.random.randint(0,2,1000)# Train RandomForestClassifiermodel=sklearn.ensemble.RandomForestClassifier().fit(df,y)# Display the original class for a random test samplex=df.iloc[0]print(f"Factual:{x.tolist()}\nFactual class:{model.predict_proba([x])}")2 - Find the CFfromcfnowimportfind_tabular# Then, we use CFNOW to generate the minimum modification to change the classificationcf_obj=find_tabular(factual=x,feat_types={c:'cat'if'cat'incelse'cont'forcindf.columns},has_ohe=True,model_predict_proba=model.predict_proba,limit_seconds=10)# Here we can see the new classprint(f"CF:{cf_obj.cfs[0]}\nCF class:{model.predict_proba([cf_obj.cfs[0]])}")Image CounterfactualsTo generate image counterfactuals, we will use some additional packages to help us.Below we show the process to generate a counterfactual for the image below:factual class: daisycf class: bee1 - Install the packagespipinstalltorchtorchvisionPillowrequestsWe also recommend to run this experiment in Jupyter Notebook, as it will be easier to visualize the results.Most of the code for this example is to load the pre-trained model and the image. The CFNOW part is very simple.2 - Loading the pre-trained model and building image classifierimportrequestsimportnumpyasnpfromPILimportImagefromtorchvisionimportmodels,transformsimporttorch# Load a pre-trained ResNet modelmodel=models.resnet50(pretrained=True)model.eval()# Define the image transformationtransform=transforms.Compose([transforms.Resize(256),transforms.CenterCrop(224),transforms.ToTensor(),transforms.Normalize(mean=[0.485,0.456,0.406],std=[0.229,0.224,0.225]),])# Fetch an image from the webimage_url="https://upload.wikimedia.org/wikipedia/commons/thumb/4/41/Sunflower_from_Silesia2.jpg/320px-Sunflower_from_Silesia2.jpg"response=requests.get(image_url,stream=True)image=np.array(Image.open(response.raw))defpredict(images):iflen(np.shape(images))==4:# Convert the list of numpy arrays to a batch of tensorsinput_images=torch.stack([transform(Image.fromarray(image.astype('uint8')))forimageinimages])eliflen(np.shape(images))==3:input_images=transform(Image.fromarray(images.astype('uint8')))else:raiseValueError("The input must be a list of images or a single image.")# Check if a GPU is available and if not, use a CPUdevice=torch.device("cuda"iftorch.cuda.is_available()else"cpu")input_images=input_images.to(device)model.to(device)# Perform inferencewithtorch.no_grad():outputs=model(input_images)# Return an array of prediction scores for each imagereturntorch.asarray(outputs).cpu().numpy()LABELS_URL="https://raw.githubusercontent.com/anishathalye/imagenet-simple-labels/master/imagenet-simple-labels.json"defpredict_label(outputs):# Load the labels used by the pre-trained modellabels=requests.get(LABELS_URL).json()# Get the predicted labelspredicted_idxs=[np.argmax(od)forodinoutputs]predicted_labels=[labels[idx.item()]foridxinpredicted_idxs]returnpredicted_labels# Check the prediction for the imagepredicted_label=predict([np.array(image)])print("Predicted labels:",predict_label(predicted_label))To find the CF you just need to:3 - Find the CFfromcfnowimportfind_imagecf_img=find_image(img=image,model_predict=predict)cf_img_hl=cf_img.cfs[0]print("Predicted labels:",predict_label(predict([cf_img_hl])))# Show the CF imageImage.fromarray(cf_img_hl.astype('uint8'))Text CounterfactualsYou can also generate counterfactuals for embedding models.For this example, you will need to install the following packages:1 - Install the packagespipinstalltransformers2 - Load the pre-trained modelfromtransformersimportDistilBertTokenizer,DistilBertForSequenceClassificationfromtransformersimportpipelineimportnumpyasnp# Load pre-trained model and tokenizer for sentiment analysismodel_name="distilbert-base-uncased-finetuned-sst-2-english"tokenizer=DistilBertTokenizer.from_pretrained(model_name)model=DistilBertForSequenceClassification.from_pretrained(model_name)# Define the sentiment analysis pipelinesentiment_analysis=pipeline("sentiment-analysis",model=model,tokenizer=tokenizer)# Define a simple datasettext_factual="I liked this movie because it was funny but my friends did not like it because it was too long and boring."result=sentiment_analysis(text_factual)print(f"{text_factual}:{result[0]['label']}(confidence:{result[0]['score']:.2f})")defpred_score_text(list_text):iftype(list_text)==str:sa_pred=sentiment_analysis(list_text)[0]sa_score=sa_pred['score']sa_label=sa_pred['label']returnsa_scoreifsa_label=="POSITIVE"else1.0-sa_scorereturnnp.array([sa["score"]ifsa["label"]=="POSITIVE"else1.0-sa["score"]forsainsentiment_analysis(list_text)])In the code above, we can see the factual text has a NEGATIVE classification with a high confidence.I liked this movie because it was funny but my friends did not like it because it was too long and boring.: NEGATIVE (confidence: 0.98)3 - Find the CFfromcfnowimportfind_textcf_text=find_text(text_input=text_factual,textual_classifier=pred_score_text)result_cf=sentiment_analysis(cf_text.cfs[0])print(f"CF:{cf_text.cfs[0]}:{result_cf[0]['label']}(confidence:{result_cf[0]['score']:.2f})")The CF text has a POSITIVE classification with a high confidence.CF: I liked this movie because it was funny  my friends did not like it because it was too long and boring.: POSITIVE (confidence: 0.93)In the example above, we can see how minimal the difference is to a change in the classification. Where just one word (but) was removed from the text.How to citeIf you use CFNOW in your research, please cite the following paper:@article{DEOLIVEIRA2023,
title = {A model-agnostic and data-independent tabu search algorithm to generate counterfactuals for tabular, image, and text data},
journal = {European Journal of Operational Research},
year = {2023},
issn = {0377-2217},
doi = {https://doi.org/10.1016/j.ejor.2023.08.031},
url = {https://www.sciencedirect.com/science/article/pii/S0377221723006598},
author = {Raphael Mazzine Barbosa {de Oliveira} and Kenneth Sörensen and David Martens},
}Version 0.0.6Add Counterplots feature namesVersion 0.0.0Initial package version