From 1a6bfe8cf33b1323c78a279d8fad9d006e729932 Mon Sep 17 00:00:00 2001 From: Naim Date: Mon, 28 Oct 2024 02:53:16 +0100 Subject: [PATCH] Initial Codebase (Notebooks) and Documentation for Credit Card Fraud Detection Workflow --- ai-credit-fraud-workflow/Dockerfile | 4 + ai-credit-fraud-workflow/LICENSE | 201 ++ ai-credit-fraud-workflow/README.md | 86 +- .../conda/fraud_conda_env.yaml | 60 + .../data/Sparkov/README.md | 7 + .../data/Sparkov/raw/READEME.md | 5 + .../data/Sparkov/xgb/README.md | 2 + .../data/TabFormer/README.md | 3 + .../data/TabFormer/gnn/README.md | 0 .../data/TabFormer/raw/README.md | 0 .../data/TabFormer/xgb/README.md | 0 ai-credit-fraud-workflow/docs/background.md | 46 + ai-credit-fraud-workflow/docs/datasets.md | 96 + .../docs/run_notebooks.md | 79 + ai-credit-fraud-workflow/docs/setup.md | 191 ++ ai-credit-fraud-workflow/docs/workflow.md | 50 + ai-credit-fraud-workflow/img/3-partite.jpg | Bin 0 -> 258079 bytes ai-credit-fraud-workflow/img/High-Level.jpg | Bin 0 -> 17862 bytes .../img/Model-Building.png | Bin 0 -> 18195 bytes ai-credit-fraud-workflow/img/Splash.jpg | Bin 0 -> 13716 bytes .../img/this-workflow.jpg | Bin 0 -> 9276 bytes ...nference_gnn_based_xgboost_TabFormer.ipynb | 671 ++++++ .../notebooks/inference_xgboost_Sparkov.ipynb | 560 +++++ .../inference_xgboost_TabFormer.ipynb | 578 +++++ .../notebooks/preprocess_Sparkov.ipynb | 1968 +++++++++++++++++ .../notebooks/preprocess_Tabformer.ipynb | 1944 ++++++++++++++++ .../notebooks/train_gnn_based_xgboost.ipynb | 1161 ++++++++++ .../notebooks/train_xgboost.ipynb | 501 +++++ ai-credit-fraud-workflow/requirements.txt | 2 + 29 files changed, 8200 insertions(+), 15 deletions(-) create mode 100644 ai-credit-fraud-workflow/Dockerfile create mode 100644 ai-credit-fraud-workflow/LICENSE create mode 100644 ai-credit-fraud-workflow/conda/fraud_conda_env.yaml create mode 100644 ai-credit-fraud-workflow/data/Sparkov/README.md create mode 100644 ai-credit-fraud-workflow/data/Sparkov/raw/READEME.md create mode 100644 ai-credit-fraud-workflow/data/Sparkov/xgb/README.md create mode 100644 ai-credit-fraud-workflow/data/TabFormer/README.md create mode 100644 ai-credit-fraud-workflow/data/TabFormer/gnn/README.md create mode 100644 ai-credit-fraud-workflow/data/TabFormer/raw/README.md create mode 100644 ai-credit-fraud-workflow/data/TabFormer/xgb/README.md create mode 100644 ai-credit-fraud-workflow/docs/background.md create mode 100644 ai-credit-fraud-workflow/docs/datasets.md create mode 100644 ai-credit-fraud-workflow/docs/run_notebooks.md create mode 100644 ai-credit-fraud-workflow/docs/setup.md create mode 100644 ai-credit-fraud-workflow/docs/workflow.md create mode 100644 ai-credit-fraud-workflow/img/3-partite.jpg create mode 100644 ai-credit-fraud-workflow/img/High-Level.jpg create mode 100644 ai-credit-fraud-workflow/img/Model-Building.png create mode 100644 ai-credit-fraud-workflow/img/Splash.jpg create mode 100644 ai-credit-fraud-workflow/img/this-workflow.jpg create mode 100644 ai-credit-fraud-workflow/notebooks/inference_gnn_based_xgboost_TabFormer.ipynb create mode 100644 ai-credit-fraud-workflow/notebooks/inference_xgboost_Sparkov.ipynb create mode 100644 ai-credit-fraud-workflow/notebooks/inference_xgboost_TabFormer.ipynb create mode 100644 ai-credit-fraud-workflow/notebooks/preprocess_Sparkov.ipynb create mode 100644 ai-credit-fraud-workflow/notebooks/preprocess_Tabformer.ipynb create mode 100644 ai-credit-fraud-workflow/notebooks/train_gnn_based_xgboost.ipynb create mode 100644 ai-credit-fraud-workflow/notebooks/train_xgboost.ipynb create mode 100644 ai-credit-fraud-workflow/requirements.txt diff --git a/ai-credit-fraud-workflow/Dockerfile b/ai-credit-fraud-workflow/Dockerfile new file mode 100644 index 0000000..43d2c7c --- /dev/null +++ b/ai-credit-fraud-workflow/Dockerfile @@ -0,0 +1,4 @@ +FROM nvcr.io/nvidia/pyg:24.09-py3 +WORKDIR /ai-credit-fraud-workflow +COPY requirements.txt /ai-credit-fraud-workflow +RUN pip install --no-cache-dir -r requirements.txt diff --git a/ai-credit-fraud-workflow/LICENSE b/ai-credit-fraud-workflow/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/ai-credit-fraud-workflow/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/ai-credit-fraud-workflow/README.md b/ai-credit-fraud-workflow/README.md index 32692c2..7653a14 100644 --- a/ai-credit-fraud-workflow/README.md +++ b/ai-credit-fraud-workflow/README.md @@ -1,20 +1,76 @@ - +__Note__: The sample datasets must be downloaded manually (see Setup) -# AI Credit Card Fraud Workflow -Future home of the AI Credit Card Fraud Workflow. +Table of Content +* [Background](./docs/background.md) +* [This Workflow](./docs/workflow.md) +* [Datasets and Data Prep](./docs/datasets.md) +* [Setup](./docs/setup.md) + +Executing these examples: +1. Setup your environment or container (see [Setup](./docs/setup.md)) +1. Download the datasets (see [Datasets](./docs/datasets.md)) +1. Start Jupyter +1. Run the [Notebooks](./docs/run_notebooks.md) + * Determine which dataset you want (Notebook names are related to a dataset) + * Run the data pre-processing Notebook + * Run the GNN training Notebook + * Run the inference Notebook + + +### Notebooks need to executed in the correct order +The notebooks need to be executed in the correct order. For a particular dataset, the preprocessing notebook must be executed before the training notebook. Once the training notebook produces models, the inference notebook can be executed to run inference on unseen data. + + +For example, for the TabFormer dataset, the notebooks need to be executed in the following order - + + - preprocess_Tabformer.ipynb + - train_gnn_based_xgboost.ipynb + - inference_gnn_based_xgboost_TabFormer.ipynb + +To train a standalone XGBoost model, that doesn't utilize node embedding, run the following two notebooks in the following oder - + + - train_xgboost.ipynb + - inference_xgboost_TabFormer.ipynb + +__Note__: Before executing `train_xgboost.ipynb` and `train_gnn_based_xgboost.ipynb` notebooks, make sure that the right dataset is selected in the second code cell of of the notebooks. + +```code + DATASET = TABFORMER +``` + +

+ + +## Copyright and License +Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. + +
+ + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/ai-credit-fraud-workflow/conda/fraud_conda_env.yaml b/ai-credit-fraud-workflow/conda/fraud_conda_env.yaml new file mode 100644 index 0000000..0af8bd8 --- /dev/null +++ b/ai-credit-fraud-workflow/conda/fraud_conda_env.yaml @@ -0,0 +1,60 @@ +# This file is generated by `rapids-dependency-file-generator`. +# To make changes, edit ../../dependencies.yaml and run `rapids-dependency-file-generator`. +channels: +- rapidsai +- rapidsai-nightly +- pyg +- conda-forge +- nvidia +dependencies: +- breathe +- conda-forge::category_encoders +- cmake>=3.26.4,!=3.30.0 +- cuda-cudart-dev +- cuda-nvtx-dev +- cuda-profiler-api +- cuda-version=12.1 +- cudf==24.8.* +- cugraph==24.8.* +- cugraph-pyg==24.8.* +- cuml==24.8.* +- cupy>=12.0.0 +- cython>=3.0.0 +- doxygen +- graphviz +- ipython +- libcublas-dev +- libcurand-dev +- libcusolver-dev +- libcusparse-dev +- nbsphinx +- ninja +- notebook>=0.5.0 +- numba>=0.57 +- numpy>=1.23,<2.0a0 +- conda-forge::matplotlib +- pandas +- pre-commit +- pydantic +- pydata-sphinx-theme +- pyg::pyg +- pylibcugraphops==24.8.* +- pylibraft==24.8.* +- pylibwholegraph==24.8.* +- pytest +- pytorch-cuda=12.1 +- pytorch::pytorch>=2.0,<2.2.0a0 +- py-xgboost-gpu +- rmm==24.8.* +- scikit-build-core>=0.7.0 +- scipy +- setuptools>=61.0.0 +- sphinx-copybutton +- sphinx-markdown-tables +- sphinx<6 +- sphinxcontrib-websupport +- torchdata +- tensordict +- wget +- wheel +name: fraud_conda_env diff --git a/ai-credit-fraud-workflow/data/Sparkov/README.md b/ai-credit-fraud-workflow/data/Sparkov/README.md new file mode 100644 index 0000000..36d258b --- /dev/null +++ b/ai-credit-fraud-workflow/data/Sparkov/README.md @@ -0,0 +1,7 @@ +# Sparkov data folder + +please download the data here + +https://www.kaggle.com/datasets/kartik2112/fraud-detection + + diff --git a/ai-credit-fraud-workflow/data/Sparkov/raw/READEME.md b/ai-credit-fraud-workflow/data/Sparkov/raw/READEME.md new file mode 100644 index 0000000..4e20a2a --- /dev/null +++ b/ai-credit-fraud-workflow/data/Sparkov/raw/READEME.md @@ -0,0 +1,5 @@ +# Sparkov raw data folder + +Place the extract files here. +* fraudTest.csv +* fraudTrain.csv \ No newline at end of file diff --git a/ai-credit-fraud-workflow/data/Sparkov/xgb/README.md b/ai-credit-fraud-workflow/data/Sparkov/xgb/README.md new file mode 100644 index 0000000..b41ac3c --- /dev/null +++ b/ai-credit-fraud-workflow/data/Sparkov/xgb/README.md @@ -0,0 +1,2 @@ +# # Sparkov XGB data folder + \ No newline at end of file diff --git a/ai-credit-fraud-workflow/data/TabFormer/README.md b/ai-credit-fraud-workflow/data/TabFormer/README.md new file mode 100644 index 0000000..6b17487 --- /dev/null +++ b/ai-credit-fraud-workflow/data/TabFormer/README.md @@ -0,0 +1,3 @@ +# IBM TabFormer Dataset + +The data needs to be downloaded manually. Please go https://ibm.ent.box.com/v/tabformer-data/folder/130747715605 and download the "transaction.tgz" file \ No newline at end of file diff --git a/ai-credit-fraud-workflow/data/TabFormer/gnn/README.md b/ai-credit-fraud-workflow/data/TabFormer/gnn/README.md new file mode 100644 index 0000000..e69de29 diff --git a/ai-credit-fraud-workflow/data/TabFormer/raw/README.md b/ai-credit-fraud-workflow/data/TabFormer/raw/README.md new file mode 100644 index 0000000..e69de29 diff --git a/ai-credit-fraud-workflow/data/TabFormer/xgb/README.md b/ai-credit-fraud-workflow/data/TabFormer/xgb/README.md new file mode 100644 index 0000000..e69de29 diff --git a/ai-credit-fraud-workflow/docs/background.md b/ai-credit-fraud-workflow/docs/background.md new file mode 100644 index 0000000..dd4b0ed --- /dev/null +++ b/ai-credit-fraud-workflow/docs/background.md @@ -0,0 +1,46 @@ +# Background +Transaction fraud is +[expected to exceed $43B by 2026](https://nilsonreport.com/articles/card-fraud-losses-worldwide/) +and poses a significant challenge upon financial institutions to detect and prevent +sophisticated fraudulent activities. Traditionally, financial institutions +have relied upon rules based techniques which are reactive in nature and +result in higher false positives and lower fraud detection accuracy. As data +volumes and attacks have become more sophisticated, accelerated machine and +graph learning techniques become mandatory and is a more proactive approach. +AI for fraud detection uses multiple machine learning models to detect anomalies +in customer behaviors and connections as well as patterns of accounts and +behaviors that fit fraudulent characteristics. + +Fraud detection has been a challenge across banking, finance, retail and +e-commerce. Fraud doesn’t only hurt organizations financially, it can also +do reputational harm. It’s a headache for consumers, as well, when fraud models +from financial services firms overreact and register false positives that shut +down legitimate transactions. Financial services sectors are developing more +advanced models using more data to fortify themselves against losses +financially and reputationally. They’re also aiming to reduce false positives +in fraud detection for transactions to improve customer satisfaction and win +greater share among merchants. + +As data needs grow and AI models expand in size, intricacy, and diversity, +energy-efficient processing power is becoming more critical to operations in +financial services. Traditional data science pipelines lack the necessary +acceleration to handle the volumes of data involved in fraud detection, +resulting in slower processing times, which limits real-time data analysis +and detection of fraud. To efficiently manage large-scale datasets and deliver +real-time performance for AI in production, financial institutions must shift +from legacy infrastructure to accelerated computing. + +The Fraud Detection AI workflow offers enterprises an end-to-end solution using +the NVIDIA accelerated computing platform for GPU-accelerated data processing +and AI deployment, enabling real-time analysis and detection of fraudulent +activities. It is important to note that there are several types of fraud. +The initial focus is on supervised credit card transaction fraud. Other areas +beyond fraud that could be converted to products include:New Account Fraud, +Account Takeover, Fraud Ring Detection, Abnormal Behavior, and Anti-Money +Laundering. + +
+
+ +[<-- Back](../README.md)
+[--> Next: This Workflow](./workflow.md) diff --git a/ai-credit-fraud-workflow/docs/datasets.md b/ai-credit-fraud-workflow/docs/datasets.md new file mode 100644 index 0000000..82f6c13 --- /dev/null +++ b/ai-credit-fraud-workflow/docs/datasets.md @@ -0,0 +1,96 @@ +# Datasets +The exemplars here are based on two different datasets with a different set of notebooks for each dataset. + +__Both datasets need to be download manually.__ + +## Dataset 1: IBM TabFormer +* https://github.com/IBM/TabFormer + * just the Credit Card Transaction Dataset and not the others +* License: Apache License Version 2.0, January 2004 +* 24 million transaction records + + +## Dataset 2: Sparkov +The data generator: + * https://github.com/namebrandon/Sparkov_Data_Generation + + +The generator was used to produce a dataset for Kaggle: + * https://www.kaggle.com/datasets/kartik2112/fraud-detection + * Released under CC0: Public Domain + * Contains 1,296,675 records with 23 fields + * one field being the "is_fraud" label which we use for training. + + +

+ + +# Data Prep + +Preprocessing, along with feature engineering are very important steps in machine learning that significantly impact model performance. Here is summary of preprocessing we performed for the two datasets + +## TabFormer + +### Data fields +* Ordinal categorical fields - 'Year', 'Month', 'Day' +* Nominal categorical fields - 'User', 'Card', 'Merchant Name', 'Merchant City', 'Merchant State', 'Zip', 'MCC', 'Errors?' +* Target label - 'Is Fraud?' + +### Preprocessing +* Missing values for 'Merchant State', 'Zip' and 'Errors?' fields are replaced with markers as these columns have nominal categorical values. +* Dollar symbol ($) in 'Amount' and extra character (,) in 'Errors?' field are removed. +* 'Time' in converted to number of minutes over the span of a day. +* 'Card' is converted to 'User' * MAX_NUMBER_OF_CARD_PER_USERS + 'Card' and finally treated as nominal categorical values to make sure that Card 0 from User 1 is different from Card 0 of User 2 +* Filtered out categorical and numerical columns that don't have significant correlation with target column +* Hot-encoded nominal categorical columns with less than nine categories and binary encoded nominal categorical columns with nine or more categories +* Scaled numerical column. As the 'Amount' field has a few extreme values, we scaled the field with a Robust Scaler. +* We save the fitted transformer, transformed train and test data in CSV files. + +NOTE: Binary encoding and scaling performed using a column transformer, which is composed of encoders and a scaler. + +### To create Graph from GNN +* Assigned unique and consecutive ids for the transactions, which become node ids of the transactions in the Graph. +* Card (or user) ids are used to create consecutive ids for user nodes +* Merchant strings are converted mapped to consecutive ids for merchant nodes. +* If an user U makes a transaction T to a merchant M, user node U will have an edge (directional or bidirectional depending on flag) to transaction node T, and the transaction node T will be connected with an edge (directional or bidirectional depending on flag) to the merchant node M. +* Transformed transaction node features are saved in a csv file using node id as index. +* Merchant and User nodes are initialized with zero vectors of same length of a transaction node features. +* Target values of all the nodes are saved in a separate CSV file which are loaded during GNN training. + + +## Sparkov + +### Data fields +* Nominal categorical fields - 'cc_num', 'merchant', 'category', 'first', 'last', 'street', 'city', 'state', 'zip', 'job', 'trans_num' +* Numerical fields - 'amt', 'lat', 'long', 'city_pop', 'merch_lat', 'merch_long' +* Timestamp fields - 'dob', 'trans_date_trans_time', 'unix_time' +* Target label - 'is_fraud' + +### Preprocessing +* From 'unix_time' and ('lat', 'long') and ('merchant_lat', 'merchant_long') we calculated the transaction 'speed'. +* Converted 'dob' to age. +* Converted 'trans_date_trans_time' in to number of minutes over the span of a day. + +* Filter out categorical and numerical columns that don't have significant correlation with target column. +* Binary encoded nominal categorical columns. +* Scaled numerical columns. As the 'amt' field has a few extreme values, we scaled the field with a Robust Scaler. The 'speed' and 'age' are scaled with standard scaler. +* We save the fitted transformer, transformed train and test data in CSV files. + +NOTE: Binary encoding and scaling performed using a column transformer, which is composed of encoders and scalers. + +### To create Graph from GNN +* Assigned unique and consecutive ids for the transactions, which become node ids of the transactions in the Graph. +* 'cc_num' are used to create consecutive ids for user nodes +* Merchant strings are converted mapped to consecutive ids for merchant nodes. +* If an user U makes a transaction T to a merchant M, user node U will have an edge (directional or bidirectional depending on flag) to transaction node T, and the transaction node T will be connected with an edge (directional or bidirectional depending on flag) to the merchant node M. +* Transformed transaction node features are saved in a csv file using node id as index. +* Merchant and User nodes are initialized with zero vectors of same length of a transaction node features. +* Target values of all the nodes are saved in a separate CSV file which are loaded during GNN training. + + +
+
+ +[<-- Top](../README.md)
+[<-- Back: Workflow](./workflow.md)
+[--> Next: Setup](./setup.md) diff --git a/ai-credit-fraud-workflow/docs/run_notebooks.md b/ai-credit-fraud-workflow/docs/run_notebooks.md new file mode 100644 index 0000000..d62a0a2 --- /dev/null +++ b/ai-credit-fraud-workflow/docs/run_notebooks.md @@ -0,0 +1,79 @@ +# Running the Notebooks +This page will go over the sequence to run the various notebooks. +Please note that once the data is prepared, both datasets leverage the same notebooks for training. + +__Note:__ It is assumed that the data has been downloaded and placed in the raw folder for each respective dataset. +if not, please see: [setup](./setup.md) + +__Note__:It is also assumed that Jupyter has been started and the conda environment has been added. See [setup](./setup.md) + +__Note__: Before executing `train_xgboost.ipynb` and `train_gnn_based_xgboost.ipynb` notebooks, make sure that the right dataset is selected in the second code cell of of the notebooks. + +For TabFormer dataset, set +```code + DATASET = TABFORMER +``` +and for the Sparkov dataset, set +```code + DATASET = SPARKOV +``` + +## TabFormer + +### Step 1: Prepare the data +run `notebooks/preprocess_Tabformer.ipynb` + +This will produce a number of files under `./data/TabFormer/gnn` and `./data/TabFormer/xgb`. It will also save data preprocessor pipeline `preprocessor.pkl` and a few variables in a json file `variables.json` under `./data/TabFormer` directory. + +### Step 2: Build the model +run `notebooks/train_gnn_based_xgboost.ipynb` + +This will produce two files for the GNN-based XGBoost model under `./data/TabFormer/models` directory. + +### Step 3: Run Inference +run `notebooks/inference_gnn_based_xgboost_TabFormer.ipynb` + +### Optional: Pure XGBoost +Two additional notebooks are provided to build a pure XGBoost model (without GNN) and perform inference using that model. + +__Train__ +run `notebooks/train_xgboost.ipynb` + +This will produce a XGBoost model under `./data/TabFormer/models` directory. + +__Inference__ +run `notebooks/inference_xgboost_TabFormer.ipynb` + + + +## Sparkov + +__Note__ Make sure to restart jupyter kernel before running `train_gnn_based_xgboost.ipynb` for the second dataset. + +### Step 1: Prepare the data +run `notebooks/preprocess_Sparkov.ipynb` + +This will produce a number of files under `./data/Sparkov/gnn` and `./data/Sparkov/xgb`. It will also save data preprocessor pipeline `preprocessor.pkl` and a few variables in a json file `variables.json` under `./data/Sparkov` directory. + +### Step 2: Build the model +run `notebooks/train_gnn_based_xgboost.ipynb` + +This will produce two files for the GNN-based XGBoost model under `./data/Sparkov/models` directory. + + +### Optional: Pure XGBoost +Two additional notebooks are provided to build a pure XGBoost model (without GNN) and perform inference using that model. + +__Train__ +run `notebooks/train_xgboost.ipynb` + +This will produce a XGBoost model under `./data/Sparkov/models` directory. + +__Inference__ +run `notebooks/inference_xgboost_Sparkov.ipynb` + + +
+
+ +[<-- Top](../README.md)
diff --git a/ai-credit-fraud-workflow/docs/setup.md b/ai-credit-fraud-workflow/docs/setup.md new file mode 100644 index 0000000..98dd1ea --- /dev/null +++ b/ai-credit-fraud-workflow/docs/setup.md @@ -0,0 +1,191 @@ +# Setup +There are a number of ways that the notebooks can be executed. + + + +## Step 1: Clone the repo + +cd into the base directory where you pkan to house the code. + +```bash +git clone https://github.com/nv-morpheus/morpheus-experimental +cd ./morpheus-experimental/ai-credit-fraud-workflow +``` + +## Step 2: Download the datasets + +__TabFormer__
+1. Download the dataset: https://ibm.ent.box.com/v/tabformer-data/folder/130747715605 +2. untar and uncompreess the file: `tar -xvzf ./transactions.tgz` +3. Place the file in the ___"./data/TabFormer/raw"___ folder + + +__Sparkov__
+1. Download the dataset from: https://www.kaggle.com/datasets/kartik2112/fraud-detection +2. Unzip the "archive.zip" file + * that will produce a folder with two files +3. place the two files under the __"./data/'Sparkov/raw"__ folder + +## Step 3: Create a new conda environment + +You can get a minimum installation of Conda and Mamba using [Miniforge](https://github.com/conda-forge/miniforge). + +And then create an environment using the following command. + +Make sure that your shell or command prompt is pointint to `morpheus-experimental/ai-credit-fraud-workflow` before running `mamba env create`. + +```bash +~/morpheus-experimental/ai-credit-fraud-workflow$ mamba env create -f conda/fraud_conda_env.yaml +``` + + +Alternatively, you can install [MiniConda](https://docs.anaconda.com/miniconda/miniconda-install) and run the following commands to create an environment to run the notebooks. + + Install `mamba` first with + +```bash +conda install conda-forge::mamba +``` +And, then run `mamba env create` from the right directory as shown below. + +```bash +~/morpheus-experimental/ai-credit-fraud-workflow$ mamba env create -f conda/fraud_conda_env.yaml +``` + +Finally, activate the environment. + +```bash +conda activate fraud_conda_env +``` + +All the notebooks are located under `morpheus-experimental/ai-credit-fraud-workflow/notebooks`. + +```bash +~/morpheus-experimental/ai-credit-fraud-workflow$ cd notebooks +~/morpheus-experimental/ai-credit-fraud-workflow/notebooks$ ls -1 +inference_gnn_based_xgboost_TabFormer.ipynb +inference_xgboost_Sparkov.ipynb +inference_xgboost_TabFormer.ipynb +preprocess_Sparkov.ipynb +preprocess_Tabformer.ipynb +train_gnn_based_xgboost.ipynb +train_xgboost.ipynb +``` + +Now you can run the notebooks from VS Code. Note that you need to select `fraud_conda_env` as the kernel in VS Code to run the notebooks. Alternatively, you can run the notebooks using Jupyter or Jupyter labs. You will need to add the conda environment: `ipython kernel install --user --name= fraud_conda_env` + + +#### NOTE: Notebooks need to be executed in the correct order +The notebooks need to be executed in the correct order. For a particular dataset, the preprocessing notebook must be executed before the training notebook. Once the training notebook produces models, the inference notebook can be executed to run inference on unseen data. + +For example, for the TabFormer dataset, the notebooks need to be executed in the following order - + + - preprocess_Tabformer.ipynb + - train_gnn_based_xgboost.ipynb + - inference_gnn_based_xgboost_TabFormer.ipynb + +The train a standalone XGBoost model, that doesn't utilize node embedding, run the following two notebooks in the following oder - + + - train_xgboost.ipynb + - inference_xgboost_TabFormer.ipynb + +## Docker container (alternative,to creating a conda environment) + +If you don't want to create a conda environment locally, you can spin up a Docker container either on your local machine or a remote one and execute the notebooks from a browser or the terminal. + +### Running locally + +Clone the [repo](https://github.com/nv-morpheus/morpheus-experimental) and `cd` into the project folder +```bash +git clone https://github.com/nv-morpheus/morpheus-experimental +cd morpheus-experimental/ai-credit-fraud-workflow +``` + + +### Build docker image and run a container with port forwarding + +Build the docker image from `morpheus-experimental/ai-credit-fraud-workflow` +```bash +~/morpheus-experimental/ai-credit-fraud-workflow$ docker build --no-cache -t fraud-detection-app . +``` + +And, run a container from `morpheus-experimental/ai-credit-fraud-workflow` + +```bash +~/morpheus-experimental/ai-credit-fraud-workflow$ docker run --gpus all -it --rm -v $(pwd):/ai-credit-fraud-workflow -p 8888:8888 fraud-detection-app +``` + +This will give you an interactive shell into the docker container. All the notebooks should be accessible under `/ai-credit-fraud-workflow/notebooks` inside the container. + +__Note__: `-v $(pwd):/ai-credit-fraud-workflow` in the `docker run` command will mount `morpheus-experimental/ai-credit-fraud-workflow` directory from the host machine into the Docker container as `/ai-credit-fraud-workflow`. + +You can list the notebooks from the interactive shell of the docker container. Note that you will have a different container id than shown (7c593d76f681) in the example output below. + +```bash +root@7c593d76f681:/ai-credit-fraud-workflow# ls +Dockerfile LICENSE README.md conda data docs img notebooks python requirements.txt + +root@7c593d76f681:/ai-credit-fraud-workflow# cd notebooks/ + +root@7c593d76f681:/ai-credit-fraud-workflow/notebooks# ls -1 +inference_gnn_based_xgboost_TabFormer.ipynb +inference_xgboost_Sparkov.ipynb +inference_xgboost_TabFormer.ipynb +preprocess_Sparkov.ipynb +preprocess_Tabformer.ipynb +train_gnn_based_xgboost.ipynb +train_xgboost.ipynb +``` + +### Launch Jupyter Notebook inside the container + +Run the following command from interactive shell inside the docker container. +```bash +root@7c593d76f681:/ai-credit-fraud-workflow# jupyter notebook . +``` +It will display an url with token +http://127.0.0.1:8888/tree?token= + +Now you can browse to the `notebooks` folder, and run or edit the notebooks from a browser at the url. + + +If you are not interested in running/editing the notebooks from a browser, you can omit the port forwarding option. + +```bash +~/morpheus-experimental/ai-credit-fraud-workflow$ docker build --no-cache -t fraud-detection-app . +``` + +```bash +~/morpheus-experimental/ai-credit-fraud-workflow$ docker run --gpus all -it --rm -v $(pwd):/ai-credit-fraud-workflow fraud-detection-app +``` + +This will give you an interactive shell inside the docker container. + +And then, you can run any notebook using the following command inside the container. + +```bash +root@7c593d76f681:/ai-credit-fraud-workflow# cd notebooks +root@7c593d76f681:/ai-credit-fraud-workflow# jupyter nbconvert --to notebook --execute [NAME_OF_THE_NOTBOOK].ipynb --output [NAME_OF_THE_OUTPUT_NOTEBOOK].ipynb +``` + +### Running on a remote machine + +### Copy the dataset to the right folder + +```bash +scp path/to/downloaded-file-in your-local-machine user@remote_host_name:path/to/ai-credit-fraud-workflow/data/[DATASET_NAME]/raw +``` + +Make sure to place the unzipped csv file in `ai-credit-fraud-workflow/data/[DATASET_NAME]/raw` folder. + + +Login to your remote machine from your host machine, with ssh tunneling/port forwarding + +```bash +ssh -L 127.0.0.1:8888:127.0.0.1:8888 USER@REMOTE_HOST_NAME_OR_IP +``` + +Then follow the steps described under section `Launch Jupyter Notebook inside the container` . Finally, go to the url from a browser in your host machine to run/edit the notebooks. + +[<-- Top](../README.md)
+[<-- Back: Datasets](./datasets.md)
diff --git a/ai-credit-fraud-workflow/docs/workflow.md b/ai-credit-fraud-workflow/docs/workflow.md new file mode 100644 index 0000000..dfa3809 --- /dev/null +++ b/ai-credit-fraud-workflow/docs/workflow.md @@ -0,0 +1,50 @@ +# High-Level Architecture +The general fraud architecture, as depicted below at a very high-level, uses +Morpheus to continually inspect and classify all incoming data. What is not +shown in the diagram is what a customer should do if fraud is detected, the +architecture simply shows tagged data being sent to downstream processing. +Those processes should be well defined in the customers’ organization. +Additionally, the post-detected data, what we are calling the Tagged Data +Stream, should be stored in a database or data lake. Cleaning and preparing +the data could be done using Spark RAPIDS. + +Fraud attacks are continually evolving and therefore it is important to always +be using the latest model. That requires the model(s) to be updated often as +possible. Therefore, the diagram depicts a loop where the GNN Model Building +process is run periodically, the frequency of which is dependent on model +training latency. Given how dynamic this industry is with evolving fraud +trends, institutions who train models adaptively on a frequent basis tend to +have better fraud prevention KPIs as compared to their competitors. + +
+

+ +

+
+ +# This Workflow +The above architecture would be typical within a larger financial system where incoming data run through the inference engine first and then periodically a new model build. However, for this example, the workflow is will start with model building and end with Inference. The workflow is depicted below: + +
+

+ +

+
+ + 1. __Data Prep__: the sample dataset is cleaned and prepared, using tools like NVIDIA RAPIDS for efficiency. Data preparation and feature engineering has a significant impact on the performance of model produced. See the section of data preparation for the step we did get the best results + - Input: The sample dataset + - Output: Two files; (1) a data set for training the model and a (2) dataset to be used for inference. + +2. __Model Building__: this process takes the training data and feeds it into cugraph-pyg for GNN training. However, rather than having the GNN produce a model, the last layer of the GNN is extracted as embeddings and feed into XGBoost for production of a model. + - Input: Training data file + - Output: an XGBoost model and GNN model that encodes the data + +3. __Inference__: The test dataset, extracted from the sample dataset, is feed into the Inference engine. The output is a confusion matrix showing the number of detected fraud, number of missed fraud, and number of misidentified fraud (false positives). + + +
+
+ +[<-- Top](../README.md)
+[<-- Back: Background](./background.md)
+[--> Next: Datasets](./datasets.md) diff --git a/ai-credit-fraud-workflow/img/3-partite.jpg b/ai-credit-fraud-workflow/img/3-partite.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0d6dbf92211d7fe841cc2534525cf034c61a391e GIT binary patch literal 258079 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%0A|x)3;TY}O4wCe8H=+*tX<)t{+rcV~PT>BC({ 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 Pzg3m|e^K@6zK{G5WpF(t literal 0 HcmV?d00001 diff --git a/ai-credit-fraud-workflow/img/High-Level.jpg b/ai-credit-fraud-workflow/img/High-Level.jpg new file mode 100644 index 0000000000000000000000000000000000000000..606113132402612da6e9dcb38a5fc232865f9a4e GIT binary patch literal 17862 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 MoU`D6PcohOKh`!{+W-In literal 0 HcmV?d00001 diff --git a/ai-credit-fraud-workflow/img/this-workflow.jpg b/ai-credit-fraud-workflow/img/this-workflow.jpg new file mode 100644 index 0000000000000000000000000000000000000000..15fa2f6ff78be5ad2c05cb11115e0e15aabdfcb6 GIT binary patch literal 9276 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 E1)&S%f&c&j literal 0 HcmV?d00001 diff --git a/ai-credit-fraud-workflow/notebooks/inference_gnn_based_xgboost_TabFormer.ipynb b/ai-credit-fraud-workflow/notebooks/inference_gnn_based_xgboost_TabFormer.ipynb new file mode 100644 index 0000000..e6edd21 --- /dev/null +++ b/ai-credit-fraud-workflow/notebooks/inference_gnn_based_xgboost_TabFormer.ipynb @@ -0,0 +1,671 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Inference on TabFormer Data\n", + "This notebook loads a pre-trained GNN (GraphSAGE) model and an XGBoost model and runs inference on raw data." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Goals\n", + "* Outline the steps to transform new raw data before feeding it into the models.\n", + "* Simulate the use of trained models on new data during inference." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Import packages" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import cudf\n", + "import pickle\n", + "import torch\n", + "import torch.nn as nn\n", + "import torch.nn.functional as F\n", + "from torch_geometric.nn import SAGEConv\n", + "import os\n", + "import xgboost as xgb" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Path to the pre-trained GraphSAGE and the XGBoost models" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "dataset_base_path = '../data/TabFormer'\n", + "model_root_dir = os.path.join(dataset_base_path, 'models')\n", + "gnn_model_path = os.path.join(model_root_dir, 'node_embedder.pth')\n", + "xgb_model_path = os.path.join(model_root_dir, 'embedding_based_xgb_model.json')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Definition of the trained GraphSAGE model" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "class GraphSAGE(torch.nn.Module):\n", + " def __init__(self, in_channels, hidden_channels, out_channels, n_hops, dropout_prob=0.25):\n", + " super(GraphSAGE, self).__init__()\n", + "\n", + " # list of conv layers\n", + " self.convs = nn.ModuleList()\n", + " # add first conv layer to the list\n", + " self.convs.append(SAGEConv(in_channels, hidden_channels))\n", + " # add the remaining conv layers to the list\n", + " for _ in range(n_hops - 1):\n", + " self.convs.append(SAGEConv(hidden_channels, hidden_channels))\n", + " \n", + " # output layer\n", + " self.fc = nn.Linear(hidden_channels, out_channels) \n", + "\n", + " def forward(self, x, edge_index, return_hidden=False):\n", + "\n", + " for conv in self.convs:\n", + " x = conv(x, edge_index)\n", + " x = F.relu(x)\n", + " x = F.dropout(x, p=0.5, training=self.training)\n", + " \n", + " if return_hidden:\n", + " return x\n", + " else:\n", + " return self.fc(x)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "tags": [ + "parameters" + ] + }, + "source": [ + "### Load the models" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Load the pre-trained GraphSAGE model" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Load GNN model for generating node embeddings\n", + "gnn_model = torch.load(gnn_model_path)\n", + "gnn_model.eval() # Set the model to evaluation mode" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Load the pre-trained XGBoost model" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "# Load xgboost model for node classification\n", + "loaded_bst = xgb.Booster()\n", + "loaded_bst.load_model(xgb_model_path)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Define a function to evaluate the XGBoost model" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "from cuml.metrics import confusion_matrix\n", + "from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_auc_score\n", + "import cupy as cp\n", + "from torch.utils.dlpack import to_dlpack\n", + "\n", + "def evaluate_xgboost(bst, embeddings, labels):\n", + " \"\"\"\n", + " Evaluates the performance of the XGBoost model by calculating different metrics.\n", + "\n", + " Parameters:\n", + " ----------\n", + " bst : xgboost.Booster\n", + " The trained XGBoost model to be evaluated.\n", + " embeddings : torch.Tensor\n", + " The input feature embeddings for transaction nodes.\n", + " labels : torch.Tensor\n", + " The target labels (Fraud or Non-fraud) transaction, with the same length as the number of \n", + " rows in `embeddings`.\n", + " Returns:\n", + " -------\n", + " Confusion matrix\n", + " \"\"\"\n", + "\n", + " # Convert embeddings to cuDF DataFrame\n", + " embeddings_cudf = cudf.DataFrame(cp.from_dlpack(to_dlpack(embeddings)))\n", + " \n", + " # Create DMatrix for the test embeddings\n", + " dtest = xgb.DMatrix(embeddings_cudf)\n", + " \n", + " # Predict using XGBoost on GPU\n", + " preds = bst.predict(dtest)\n", + " pred_labels = (preds > 0.5).astype(int)\n", + "\n", + " # Move labels to CPU for evaluation\n", + " labels_cpu = labels.cpu().numpy()\n", + "\n", + " # Compute evaluation metrics\n", + " accuracy = accuracy_score(labels_cpu, pred_labels)\n", + " precision = precision_score(labels_cpu, pred_labels, zero_division=0)\n", + " recall = recall_score(labels_cpu, pred_labels, zero_division=0)\n", + " f1 = f1_score(labels_cpu, pred_labels, zero_division=0)\n", + " roc_auc = roc_auc_score(labels_cpu, preds)\n", + "\n", + " print(f\"Performance of XGBoost model trained on node embeddings\")\n", + " print(f\"Accuracy: {accuracy:.4f}\")\n", + " print(f\"Precision: {precision:.4f}\")\n", + " print(f\"Recall: {recall:.4f}\")\n", + " print(f\"F1 Score: {f1:.4f}\")\n", + " print(f\"ROC AUC: {roc_auc:.4f}\")\n", + "\n", + " conf_mat = confusion_matrix(labels.cpu().numpy(), pred_labels)\n", + " print('Confusion Matrix:', conf_mat)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "___\n", + "### Evaluate the XGBoost model on untransformed test data (saved in the preprocessing notebook)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Read untransformed data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pd.set_option('future.no_silent_downcasting', True) \n", + "path_to_untransformed_data = os.path.join(dataset_base_path, 'xgb', 'untransformed_test.csv')\n", + "untransformed_df = pd.read_csv(path_to_untransformed_data)\n", + "untransformed_df.head(5)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Load the data transformer and transform the data using the loaded transformer" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "with open(os.path.join(dataset_base_path, 'preprocessor.pkl'),'rb') as f:\n", + " loaded_transformer = pickle.load(f)\n", + " transformed_data = loaded_transformer.transform(untransformed_df.loc[:, untransformed_df.columns[:-1]])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Evaluate the model on the transformed data" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n", + "# Convert data to torch tensors\n", + "X = torch.tensor(transformed_data).to(torch.float32).to(device)\n", + "y = torch.tensor(untransformed_df[untransformed_df.columns[-1]].values ).to(torch.long).to(device)" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "# Generate node embedding using the GNN model\n", + "test_embeddings = gnn_model(\n", + " X.to(device), torch.tensor([[], []], dtype=torch.int).to(device), return_hidden=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Evaluate the XGBoost model\n", + "evaluate_xgboost(loaded_bst, test_embeddings, y)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "___\n", + "## Predictions on raw input\n", + "The purpose is to demonstrate the use of the models during inference" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Read raw data" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "# Read example raw inputs\n", + "raw_file_path = os.path.join(dataset_base_path, 'xgb', 'example_transactions.csv')\n", + "data = pd.read_csv(raw_file_path)\n", + "data = data[data.columns[:-1]]\n", + "original_data = data.copy()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Transform raw data\n", + "* Perform the same set of transformations on the raw data as was done on the training data." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Rename columns before the data is fed into the pre-fitted data transformer" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "# Rename columns before the data is fed into the data transformer\n", + "COL_USER = 'User'\n", + "COL_CARD = 'Card'\n", + "COL_AMOUNT = 'Amount'\n", + "COL_MCC = 'MCC'\n", + "COL_TIME = 'Time'\n", + "COL_DAY = 'Day'\n", + "COL_MONTH = 'Month'\n", + "COL_YEAR = 'Year'\n", + "\n", + "COL_MERCHANT = 'Merchant'\n", + "COL_STATE ='State'\n", + "COL_CITY ='City'\n", + "COL_ZIP = 'Zip'\n", + "COL_ERROR = 'Errors'\n", + "COL_CHIP = 'Chip'\n", + "\n", + "\n", + "_ = data.rename(columns={\n", + " \"Merchant Name\": COL_MERCHANT,\n", + " \"Merchant State\": COL_STATE,\n", + " \"Merchant City\": COL_CITY,\n", + " \"Errors?\": COL_ERROR,\n", + " \"Use Chip\": COL_CHIP\n", + " },\n", + " inplace=True\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Handle unknown values as was done for the training data" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [], + "source": [ + "UNKNOWN_STRING_MARKER = 'XX'\n", + "UNKNOWN_ZIP_CODE = 0\n", + "MAX_NR_CARDS_PER_YEAR = 9\n", + "\n", + "data[COL_STATE] = data[COL_STATE].fillna(UNKNOWN_STRING_MARKER)\n", + "data[COL_ERROR] = data[COL_ERROR].fillna(UNKNOWN_STRING_MARKER)\n", + "data[COL_ZIP] = data[COL_ZIP].fillna(UNKNOWN_ZIP_CODE)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Convert column type and remove \"$\" and \",\" as was done for the training data" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "data[COL_AMOUNT] = data[COL_AMOUNT].str.replace(\"$\",\"\").astype(\"float\")\n", + "data[COL_STATE] = data[COL_STATE].astype('str')\n", + "data[COL_MERCHANT] = data[COL_MERCHANT].astype('str')\n", + "data[COL_ERROR] = data[COL_ERROR].str.replace(\",\",\"\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Combine User and Card to generate unique numbers as was done for the training data" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "data[COL_CARD] = data[COL_USER] * MAX_NR_CARDS_PER_YEAR + data[COL_CARD]\n", + "data[COL_CARD] = data[COL_CARD].astype('int')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Check if the transactions have unknown users or merchants" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "# Find the known merchants and (users, cards), i.e. the merchants and (users, cards) that are in training data\n", + "known_merchants = set()\n", + "known_cards = set()\n", + "\n", + "for enc in loaded_transformer.named_transformers_['binary'].named_steps['binary'].ordinal_encoder.mapping:\n", + " if enc['col'] == COL_MERCHANT:\n", + " known_merchants = set(enc['mapping'].keys())\n", + " if enc['col'] == COL_CARD:\n", + " known_cards = set(enc['mapping'].keys())" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [], + "source": [ + "# Is user, card already known\n", + "data['Is_card_known'] = data[COL_CARD].map(lambda c: c in known_cards)" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [], + "source": [ + "# Is merchant already known\n", + "data['Is_merchant_known'] = data[COL_MERCHANT].map(lambda m: m in known_merchants )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Use the same set of predictor columns as used for training" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [], + "source": [ + "numerical_predictors = [COL_AMOUNT]\n", + "nominal_predictors = [COL_ERROR, COL_CARD, COL_CHIP, COL_CITY, COL_ZIP, COL_MCC, COL_MERCHANT]\n", + "\n", + "predictor_columns = numerical_predictors + nominal_predictors" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Load the data transformer and transform the raw data" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [], + "source": [ + "with open(os.path.join(dataset_base_path, 'preprocessor.pkl'),'rb') as f:\n", + " loaded_transformer = pickle.load(f)\n", + " transformed_data = loaded_transformer.transform(data[predictor_columns])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Run prediction" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [], + "source": [ + "# Set the device to GPU if available, otherwise default to CPU\n", + "device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n", + "\n", + "# Convert data to torch tensors\n", + "X = torch.tensor(transformed_data).to(torch.float32).to(device)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "# Generate node embedding using the GraphSAGE model\n", + "transaction_embeddings = gnn_model(\n", + " X.to(device), torch.tensor([[], []], dtype=torch.int).to(device), return_hidden=True)\n", + "\n", + "embeddings_cudf = cudf.DataFrame(cp.from_dlpack(to_dlpack(transaction_embeddings)))" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [], + "source": [ + "# predict if the transaction(s) are fraud\n", + "preds = loaded_bst.predict(xgb.DMatrix(embeddings_cudf))\n", + "pred_labels = (preds > 0.5).astype(int)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### If the transactions have unknown (user, card) or merchant, mark it as fraud" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": {}, + "outputs": [], + "source": [ + "# Name of the target column\n", + "target_col_name = 'Is Fraud?'\n", + "\n", + "data[target_col_name] = pred_labels\n", + "data[target_col_name] = data.apply(\n", + " lambda row: \n", + " (row[target_col_name] == 1) or (row['Is_card_known'] == False) or (row['Is_merchant_known'] == False), axis=1)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Label the raw data as Fraud or Non-Fraud, based on prediction" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "# Change 0 to No (non-Fraud) and 1 to Yes (Fraud)\n", + "binary_to_fraud = { False: 'No', True : 'Yes'}\n", + "data[target_col_name] = data[target_col_name].map(binary_to_fraud).astype('str')\n", + "original_data[target_col_name] = data[target_col_name]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Raw data with predicted labels (Fraud or Non-Fraud)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "original_data" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Copyright and License\n", + "


\n", + "Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved.\n", + "\n", + "
\n", + "\n", + " Licensed under the Apache License, Version 2.0 (the \"License\");\n", + " you may not use this file except in compliance with the License.\n", + " You may obtain a copy of the License at\n", + " \n", + " http://www.apache.org/licenses/LICENSE-2.0\n", + " \n", + " Unless required by applicable law or agreed to in writing, software\n", + " distributed under the License is distributed on an \"AS IS\" BASIS,\n", + " WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + " See the License for the specific language governing permissions and\n", + " limitations under the License." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "mamba_env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.10" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/ai-credit-fraud-workflow/notebooks/inference_xgboost_Sparkov.ipynb b/ai-credit-fraud-workflow/notebooks/inference_xgboost_Sparkov.ipynb new file mode 100644 index 0000000..490199c --- /dev/null +++ b/ai-credit-fraud-workflow/notebooks/inference_xgboost_Sparkov.ipynb @@ -0,0 +1,560 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## This notebook loads a pre-trained XGBoost model and runs inference on raw data\n", + "__NOTE__: This XGBoost model does not leverage embeddings from the GNN (GraphSAGE) model." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Goals\n", + "* Outline the steps to transform new raw data before feeding it into the model.\n", + "* Simulate the use of the trained model on new data during inference." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Import packages" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import pickle\n", + "import json\n", + "import os\n", + "import xgboost as xgb\n", + "from cuml.metrics import confusion_matrix\n", + "from sklearn.metrics import (\n", + " accuracy_score,\n", + " precision_score,\n", + " recall_score,\n", + " f1_score,\n", + " roc_auc_score)\n", + "import numpy as np\n", + "import pandas as pd" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Path to the pre-trained XGBoost model and data" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "dataset_base_path = '../data/Sparkov'\n", + "model_root_dir = os.path.join(dataset_base_path, 'models')\n", + "model_file_name = 'xgboost_model.json'\n", + "xgb_model_path = os.path.join(model_root_dir, model_file_name)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "tags": [ + "parameters" + ] + }, + "source": [ + "#### Load the model" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "# Load xgboost model for node classification\n", + "loaded_bst = xgb.Booster()\n", + "loaded_bst.load_model(xgb_model_path)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Load column names and other global variable saved during the training" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "# Read the JSON file\n", + "with open(os.path.join(dataset_base_path, 'variables.json'), 'r') as json_file:\n", + " column_names = json.load(json_file)\n", + "\n", + "# Repopulate the variables in the global namespace\n", + "globals().update(column_names)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "___\n", + "#### Evaluate the XGBoost model on untransformed test data (saved in the preprocessing notebook)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Read untransformed data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pd.set_option('future.no_silent_downcasting', True) \n", + "path_to_untransformed_data = os.path.join(dataset_base_path, 'xgb', 'untransformed_test.csv')\n", + "untransformed_df = pd.read_csv(path_to_untransformed_data)\n", + "untransformed_df.head(5)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Load the data transformer and transform the data using the loaded transformer" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "with open(os.path.join(dataset_base_path, 'preprocessor.pkl'),'rb') as f:\n", + " loaded_transformer = pickle.load(f)\n", + " transformed_data = loaded_transformer.transform(\n", + " untransformed_df.loc[:, untransformed_df.columns[:-1]])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Evaluate the model on the transformed data" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "# Predictor columns used for training\n", + "numerical_predictors = [COL_AMOUNT, COL_SPEED, COL_AGE]\n", + "nominal_predictors = [COL_CARD, COL_ZIP, COL_MCC, COL_MERCHANT, COL_JOB]\n", + "\n", + "predictor_columns = numerical_predictors + nominal_predictors\n", + "\n", + "target_column = [COL_FRAUD]\n", + "\n", + "# transformed column names\n", + "columns_of_transformed_data = list(\n", + " map(lambda name: name.split('__')[1],\n", + " list(loaded_transformer.get_feature_names_out(predictor_columns))))" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "# Prepare features (X) and target (y)\n", + "\n", + "X = pd.DataFrame(\n", + " transformed_data, columns=columns_of_transformed_data)\n", + "\n", + "y = untransformed_df[untransformed_df.columns[-1]].values" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "# Make predictions\n", + "y_pred_prob = loaded_bst.predict(xgb.DMatrix(data=X, label=y))\n", + "\n", + "y_pred = (y_pred_prob >= 0.5).astype(int)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Compute metrics to evaluate model performance" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "# Accuracy\n", + "accuracy = accuracy_score(y, y_pred)\n", + "print(f'Accuracy: {accuracy:.4f}')\n", + "\n", + "# Confusion Matrix\n", + "conf_mat = confusion_matrix(y, y_pred)\n", + "print('Confusion Matrix:')\n", + "print(conf_mat)\n", + "\n", + "# ROC AUC Score\n", + "r_auc = roc_auc_score(y, y_pred_prob)\n", + "print(f'ROC AUC Score: {r_auc:.4f}')\n", + "\n", + "# y = cupy.asnumpy(y)\n", + "# Precision\n", + "precision = precision_score(y, y_pred)\n", + "print(f'Precision: {precision:.4f}')\n", + "\n", + "# Recall\n", + "recall = recall_score(y, y_pred)\n", + "print(f'Recall: {recall:.4f}')\n", + "\n", + "# F1 Score\n", + "f1 = f1_score(y, y_pred)\n", + "print(f'F1 Score: {f1:.4f}')\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "___\n", + "### Prediction on raw inputs\n", + "* The purpose is to demonstrate the use of the model during inference" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Read raw data" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "raw_file_path = os.path.join(dataset_base_path, 'xgb', 'example_transactions.csv')\n", + "data = pd.read_csv(raw_file_path)\n", + "data = data[data.columns[:-1]]\n", + "original_data = data.copy()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Check if the transactions have unknown users or merchants" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "# Find the known merchants and (users, cards), i.e. the merchants and (users, cards) that are in training data\n", + "known_merchants = set()\n", + "known_cards = set()\n", + "\n", + "for enc in loaded_transformer.named_transformers_['binary'].named_steps['binary'].ordinal_encoder.mapping:\n", + " if enc['col'] == COL_MERCHANT:\n", + " known_merchants = set(enc['mapping'].keys())\n", + " if enc['col'] == COL_CARD:\n", + " known_cards = set(enc['mapping'].keys())" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "# Is user, card already known\n", + "data['Is_card_known'] = data[COL_CARD].map(lambda c: c in known_cards)" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [], + "source": [ + "# Is merchant already known\n", + "data['Is_merchant_known'] = data[COL_MERCHANT].map(lambda m: m in known_merchants )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### From ('lat', 'long'), ('merchant_lat', 'merchant_long') and unix_time to compute transaction speed" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "temp_df = pd.DataFrame()\n", + "import math\n", + "# Haversine formula function\n", + "def haversine(lat1, lon1, lat2, lon2):\n", + " # Radius of Earth in km\n", + " R = 6371.0\n", + "\n", + " # Convert degrees to radians\n", + " lat1 = math.radians(lat1)\n", + " lon1 = math.radians(lon1)\n", + " lat2 = math.radians(lat2)\n", + " lon2 = math.radians(lon2)\n", + "\n", + " # Differences in coordinates\n", + " dlat = lat2 - lat1\n", + " dlon = lon2 - lon1\n", + "\n", + " # Haversine formula\n", + " a = math.sin(dlat / 2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2)**2\n", + " c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))\n", + "\n", + " # Distance in kilometers\n", + " distance = R * c\n", + " return distance\n", + "\n", + "\n", + "temp_df= data[['unix_time', 'lat', 'long', 'merch_lat', 'merch_long']].copy()\n", + "temp_df['tx_duration'] = temp_df['unix_time'].apply(lambda x: x/1e9)\n", + "temp_df['distance_km'] = temp_df.apply(\n", + " lambda row: haversine(row['lat'], row['long'], row['merch_lat'], row['merch_long']), axis=1)\n", + "\n", + "data['speed'] = (temp_df['distance_km']/temp_df['tx_duration'])\n", + "del temp_df" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Convert 'dob' to 'age' w.r.t. a reference date" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "data['dob'] = pd.to_datetime(data['dob'])\n", + "one_nanosecond = np.timedelta64(1, 'ns')\n", + "nanoseconds_in_year = 365.25 * 24 * 60 * 60 * 1e9\n", + "reference_date = pd.to_datetime('2024-10-30') \n", + "data['age'] = data['dob'].apply(lambda dob: (reference_date - dob)/ one_nanosecond / nanoseconds_in_year )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Set of predictor columns used for training the model" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "numerical_predictors = [COL_AMOUNT, COL_SPEED, COL_AGE]\n", + "nominal_predictors = [COL_CARD, COL_ZIP, COL_MCC, COL_MERCHANT, COL_JOB]\n", + "\n", + "predictor_columns = numerical_predictors + nominal_predictors\n", + "\n", + "target_column = [COL_FRAUD]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Transform input data using the pre-fitted data transformer" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [], + "source": [ + "with open(os.path.join(dataset_base_path, 'preprocessor.pkl'),'rb') as f:\n", + " loaded_transformer = pickle.load(f)\n", + " transformed_data = loaded_transformer.transform(data[predictor_columns])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Prepare data and predict if the transactions are fraud" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "X = pd.DataFrame(\n", + " transformed_data, columns=columns_of_transformed_data)\n", + "\n", + "# Predict transactions\n", + "pred_probs = loaded_bst.predict(xgb.DMatrix(X))\n", + "pred_labels = (pred_probs >= 0.5).astype(int)\n", + "\n", + "# Name of the target column\n", + "target_col_name = 'Is Fraud?'\n", + "\n", + "data[target_col_name] = pred_labels\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### If the transactions have unknown (user, card) or merchant, mark it as fraud" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "data[target_col_name] = data.apply(\n", + " lambda row: (row[target_col_name] == 1) or (row['Is_card_known'] == False) or (row['Is_merchant_known'] == False), axis=1)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Label the raw data as Fraud or Non-Fraud, based on prediction" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "# Change 0 to No (non-Fraud) and 1 to Yes (Fraud)\n", + "binary_to_text = { False: 'No', True : 'Yes'}\n", + "data[target_col_name] = data[target_col_name].map(binary_to_text).astype('str')\n", + "original_data[target_col_name] = data[target_col_name]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Transactions with predicted labels" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "original_data" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Copyright and License\n", + "
\n", + "Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved.\n", + "\n", + "
\n", + "\n", + " Licensed under the Apache License, Version 2.0 (the \"License\");\n", + " you may not use this file except in compliance with the License.\n", + " You may obtain a copy of the License at\n", + " \n", + " http://www.apache.org/licenses/LICENSE-2.0\n", + " \n", + " Unless required by applicable law or agreed to in writing, software\n", + " distributed under the License is distributed on an \"AS IS\" BASIS,\n", + " WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + " See the License for the specific language governing permissions and\n", + " limitations under the License." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "mamba_env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.10" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/ai-credit-fraud-workflow/notebooks/inference_xgboost_TabFormer.ipynb b/ai-credit-fraud-workflow/notebooks/inference_xgboost_TabFormer.ipynb new file mode 100644 index 0000000..b0b6e8d --- /dev/null +++ b/ai-credit-fraud-workflow/notebooks/inference_xgboost_TabFormer.ipynb @@ -0,0 +1,578 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## This notebook loads a pre-trained XGBoost model and runs inference on raw data\n", + "__NOTE__: This XGBoost model does not leverage embeddings from the GNN (GraphSAGE) model." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Goals\n", + "* Outline the steps to transform new raw data before feeding it into the model.\n", + "* Simulate the use of the trained model on new data during inference." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Import packages" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import pickle\n", + "import json\n", + "import os\n", + "import xgboost as xgb\n", + "from cuml.metrics import confusion_matrix\n", + "from sklearn.metrics import (\n", + " accuracy_score,\n", + " precision_score,\n", + " recall_score,\n", + " f1_score,\n", + " roc_auc_score)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Path to the pre-trained XGBoost model and data" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "dataset_base_path = '../data/TabFormer'\n", + "model_root_dir = os.path.join(dataset_base_path, 'models')\n", + "model_file_name = 'xgboost_model.json'\n", + "xgb_model_path = os.path.join(model_root_dir, model_file_name)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "tags": [ + "parameters" + ] + }, + "source": [ + "#### Load the model" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "# Load xgboost model for node classification\n", + "loaded_bst = xgb.Booster()\n", + "loaded_bst.load_model(xgb_model_path)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Load column names and other global variables saved during the training" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "# Read the JSON file\n", + "with open(os.path.join(dataset_base_path, 'variables.json'), 'r') as json_file:\n", + " column_names = json.load(json_file)\n", + "\n", + "# Repopulate the variables in the global namespace\n", + "globals().update(column_names)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "___\n", + "#### Evaluate the XGBoost model on untransformed test data (saved in the preprocessing notebook)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Read untransformed data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pd.set_option('future.no_silent_downcasting', True) \n", + "path_to_untransformed_data = os.path.join(dataset_base_path, 'xgb', 'untransformed_test.csv')\n", + "untransformed_df = pd.read_csv(path_to_untransformed_data)\n", + "untransformed_df.head(5)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Load the data transformer and transform the data using the loaded transformer" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "with open(os.path.join(dataset_base_path, 'preprocessor.pkl'),'rb') as f:\n", + " loaded_transformer = pickle.load(f)\n", + " transformed_data = loaded_transformer.transform(\n", + " untransformed_df.loc[:, untransformed_df.columns[:-1]])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Evaluate the model on the transformed data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Predictor columns used for training\n", + "numerical_predictors = [COL_AMOUNT]\n", + "nominal_predictors = [COL_ERROR, COL_CARD, COL_CHIP, COL_CITY, COL_ZIP, COL_MCC, COL_MERCHANT]\n", + "\n", + "predictor_columns = numerical_predictors + nominal_predictors\n", + "predictor_columns" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "# transformed column names\n", + "columns_of_transformed_data = list(\n", + " map(lambda name: name.split('__')[1],\n", + " list(loaded_transformer.get_feature_names_out(predictor_columns))))" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "# Prepare features (X) and target (y)\n", + "X = pd.DataFrame(\n", + " transformed_data, columns=columns_of_transformed_data)\n", + "\n", + "y = untransformed_df[untransformed_df.columns[-1]].values" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "# Make predictions\n", + "\n", + "y_pred_prob = loaded_bst.predict(xgb.DMatrix(data=X, label=y))\n", + "y_pred = (y_pred_prob >= 0.5).astype(int)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Compute metrics to evaluate the model performance" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "\n", + "# Accuracy\n", + "accuracy = accuracy_score(y, y_pred)\n", + "print(f'Accuracy: {accuracy:.4f}')\n", + "\n", + "# Confusion Matrix\n", + "conf_mat = confusion_matrix(y, y_pred)\n", + "print('Confusion Matrix:')\n", + "print(conf_mat)\n", + "\n", + "# ROC AUC Score\n", + "r_auc = roc_auc_score(y, y_pred_prob)\n", + "print(f'ROC AUC Score: {r_auc:.4f}')\n", + "\n", + "# y = cupy.asnumpy(y)\n", + "# Precision\n", + "precision = precision_score(y, y_pred)\n", + "print(f'Precision: {precision:.4f}')\n", + "\n", + "# Recall\n", + "recall = recall_score(y, y_pred)\n", + "print(f'Recall: {recall:.4f}')\n", + "\n", + "# F1 Score\n", + "f1 = f1_score(y, y_pred)\n", + "print(f'F1 Score: {f1:.4f}')\n", + "\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "___\n", + "## Prediction on raw inputs\n", + "* The purpose is to demonstrate the use of the model during inference" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Read raw data" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "# Read example raw inputs\n", + "\n", + "raw_file_path = os.path.join(dataset_base_path, 'xgb', 'example_transactions.csv')\n", + "data = pd.read_csv(raw_file_path)\n", + "data = data[data.columns[:-1]]\n", + "original_data = data.copy()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Rename columns before the data is fed into the pre-fitted data transformer" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "_ = data.rename(columns={\n", + " \"Merchant Name\": COL_MERCHANT,\n", + " \"Merchant State\": COL_STATE,\n", + " \"Merchant City\": COL_CITY,\n", + " \"Errors?\": COL_ERROR,\n", + " \"Use Chip\": COL_CHIP\n", + " },\n", + " inplace=True\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Handle unknown values as was done for the training data" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [], + "source": [ + "UNKNOWN_STRING_MARKER = 'XX'\n", + "UNKNOWN_ZIP_CODE = 0\n", + "\n", + "data[COL_STATE] = data[COL_STATE].fillna(UNKNOWN_STRING_MARKER)\n", + "data[COL_ERROR] = data[COL_ERROR].fillna(UNKNOWN_STRING_MARKER)\n", + "data[COL_ZIP] = data[COL_ZIP].fillna(UNKNOWN_ZIP_CODE)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Convert column type and remove \"$\" and \",\" as was done for the training data" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "data[COL_AMOUNT] = data[COL_AMOUNT].str.replace(\"$\",\"\").astype(\"float\")\n", + "data[COL_STATE] = data[COL_STATE].astype('str')\n", + "data[COL_MERCHANT] = data[COL_MERCHANT].astype('str')\n", + "data[COL_ERROR] = data[COL_ERROR].str.replace(\",\",\"\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Combine User and Card to generate unique numbers as was done for the training data" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "data[COL_CARD] = data[COL_USER] * MAX_NR_CARDS_PER_USER + data[COL_CARD]\n", + "data[COL_CARD] = data[COL_CARD].astype('int')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Check if the transactions have unknown users or merchants" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "# Find the known merchants and (users, cards), i.e. the merchants and (users, cards) that are in training data\n", + "known_merchants = set()\n", + "known_cards = set()\n", + "\n", + "for enc in loaded_transformer.named_transformers_['binary'].named_steps['binary'].ordinal_encoder.mapping:\n", + " if enc['col'] == COL_MERCHANT:\n", + " known_merchants = set(enc['mapping'].keys())\n", + " if enc['col'] == COL_CARD:\n", + " known_cards = set(enc['mapping'].keys())" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [], + "source": [ + "# Is user, card already known\n", + "data['Is_card_known'] = data[COL_CARD].map(lambda c: c in known_cards)" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [], + "source": [ + "# Is merchant already known\n", + "data['Is_merchant_known'] = data[COL_MERCHANT].map(lambda m: m in known_merchants )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Use the same set of predictor columns as used for training" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [], + "source": [ + "numerical_predictors = [COL_AMOUNT]\n", + "nominal_predictors = [COL_ERROR, COL_CARD, COL_CHIP, COL_CITY, COL_ZIP, COL_MCC, COL_MERCHANT]\n", + "\n", + "predictor_columns = numerical_predictors + nominal_predictors" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Load the data transformer and transform the raw data" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [], + "source": [ + "with open(os.path.join(dataset_base_path, 'preprocessor.pkl'),'rb') as f:\n", + " loaded_transformer = pickle.load(f)\n", + " transformed_data = loaded_transformer.transform(data[predictor_columns])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Run prediction" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "X = pd.DataFrame(\n", + " transformed_data, columns=columns_of_transformed_data)\n", + "\n", + "# Predict transactions\n", + "pred_probs = loaded_bst.predict(xgb.DMatrix(X))\n", + "pred_labels = (pred_probs >= 0.5).astype(int)\n", + "\n", + "# Name of the target column\n", + "target_col_name = 'Is Fraud?'\n", + "\n", + "data[target_col_name] = pred_labels\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### If the transactions have unknown (user, card) or merchant, mark it as fraud" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "data[target_col_name] = data.apply(\n", + " lambda row: \n", + " (row[target_col_name] == 1) or (row['Is_card_known'] == False) or (row['Is_merchant_known'] == False), axis=1)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Label the raw data as Fraud or Non-Fraud, based on prediction" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "# Change 0 to No (non-Fraud) and 1 to Yes (Fraud)\n", + "binary_to_text = { False: 'No', True : 'Yes'}\n", + "data[target_col_name] = data[target_col_name].map(binary_to_text).astype('str')\n", + "original_data[target_col_name] = data[target_col_name]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Transactions with predicted labels" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "original_data" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Copyright and License\n", + "
\n", + "Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved.\n", + "\n", + "
\n", + "\n", + " Licensed under the Apache License, Version 2.0 (the \"License\");\n", + " you may not use this file except in compliance with the License.\n", + " You may obtain a copy of the License at\n", + " \n", + " http://www.apache.org/licenses/LICENSE-2.0\n", + " \n", + " Unless required by applicable law or agreed to in writing, software\n", + " distributed under the License is distributed on an \"AS IS\" BASIS,\n", + " WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + " See the License for the specific language governing permissions and\n", + " limitations under the License." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "mamba_env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.10" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/ai-credit-fraud-workflow/notebooks/preprocess_Sparkov.ipynb b/ai-credit-fraud-workflow/notebooks/preprocess_Sparkov.ipynb new file mode 100644 index 0000000..f54c677 --- /dev/null +++ b/ai-credit-fraud-workflow/notebooks/preprocess_Sparkov.ipynb @@ -0,0 +1,1968 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "9c6a5b09-a601-47c6-989f-5efb42d7f4f8", + "metadata": {}, + "source": [ + "# Credit Card Transaction Data Cleanup and Prep \n", + "\n", + "This notebook shows the steps for cleanup and preparing the credit card transaction data for follow on GNN training with GraphSAGE.\n", + "\n", + "### The dataset:\n", + " * 'Generate Fake Credit Card Transaction [Data](https://www.kaggle.com/datasets/kartik2112/fraud-detection), Including Fraudulent Transactions' using https://github.com/namebrandon/Sparkov_Data_Generation\n", + " * Released under CC0: Public Domain\n", + "\n", + "Contains 1,296,675 records with 15 fields, one field being the \"is fraud\" label which we use for training.\n", + "\n", + "### Goals\n", + "The goal is to:\n", + " * Understand and transform the data\n", + " * Correlation analysis to select important predictors \n", + " * Encode categorical fields\n", + " * Scale numerical columns\n", + " * Create a continuous node index across users, merchants, and transactions\n", + " * having node ID start at zero and then be contiguous is critical for creation of Compressed Sparse Row (CSR) formatted data without wasting memory.\n", + " * Produce:\n", + " * For XGBoost:\n", + " * Training - all transactions in 2019\n", + " * Validation - all transactions between January and May in 2020\n", + " * Test. - all transactions after May 2020\n", + " * For GNN\n", + " * Training Data \n", + " * Edge List \n", + " * Feature data\n", + " * Test set - all transactions after May 2020\n", + "\n", + "\n", + "\n", + "### Graph formation\n", + "Given that we are limited to just the data in the transaction file, the ideal model would be to have a bipartite graph of Users to Merchants where the edges represent the credit card transaction and then perform Link Classification on the Edges to identify fraud. Unfortunately the current version of cuGraph does not support GNN Link Prediction. That limitation will be lifted over the next few release at which time this code will be updated. Luckily, there is precedence for viewing transactions as nodes and then doing node classification using the popular GraphSAGE GNN. That is the approach this code takes. The produced graph will be a tri-partite graph where each transaction is represented as a node.\n", + "\n", + "\n", + "\n", + "\n", + "### Features\n", + "For the XGBoost approach, there is no need to generate empty features for the Merchants. However, for GNN processing, every node needs to have the same set of feature data. Therefore, we need to generate empty features for the User and Merchant nodes. \n", + "\n", + "-----" + ] + }, + { + "cell_type": "markdown", + "id": "795bdece", + "metadata": {}, + "source": [ + "#### Import the necessary libraries. In this case will be use cuDF and perform most of the data prep in GPU\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "4b6b2bc6-a206-42c5-aae9-590672b3a202", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import math\n", + "import os\n", + "import pickle\n", + "\n", + "import cudf\n", + "import numpy as np\n", + "import pandas as pd\n", + "import scipy.stats as ss\n", + "from category_encoders import BinaryEncoder\n", + "from scipy.stats import gaussian_kde, pointbiserialr\n", + "from sklearn.compose import ColumnTransformer\n", + "from sklearn.impute import SimpleImputer\n", + "from sklearn.pipeline import Pipeline\n", + "from sklearn.preprocessing import OneHotEncoder, RobustScaler, StandardScaler\n" + ] + }, + { + "cell_type": "markdown", + "id": "81db641b", + "metadata": {}, + "source": [ + "-------\n", + "#### Define some arguments" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "016964ce", + "metadata": {}, + "outputs": [], + "source": [ + "# Whether the graph is undirected\n", + "make_undirected = True\n", + "\n", + "# Whether to spread features across Users and Merchants nodes\n", + "spread_features = False\n", + "\n", + "# Whether we should under-sample majority class (i.e. non-fraud transactions)\n", + "under_sample = True\n", + "\n", + "# Ration of fraud and non-fraud transactions in case we under-sample the majority class\n", + "fraud_ratio = 0.1\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "656e6aee-038a-4b58-9296-993e06defb35", + "metadata": {}, + "outputs": [], + "source": [ + "sparkov_base_path = '../data/Sparkov'\n", + "sparkov_raw_file_path = os.path.join(sparkov_base_path, 'raw', 'fraudTrain.csv')\n", + "sparkov_xgb = os.path.join(sparkov_base_path, 'xgb')\n", + "sparkov_gnn = os.path.join(sparkov_base_path, 'gnn')\n", + "if not os.path.exists(sparkov_xgb):\n", + " os.makedirs(sparkov_xgb)\n", + "if not os.path.exists(sparkov_gnn):\n", + " os.makedirs(sparkov_gnn)" + ] + }, + { + "cell_type": "markdown", + "id": "96fe43fe", + "metadata": {}, + "source": [ + "--------\n", + "## Load and understand the data" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "fb41e6ea-1e9f-4f14-99a4-d6d3df092a37", + "metadata": {}, + "outputs": [], + "source": [ + "# Read the dataset\n", + "data = cudf.read_csv(sparkov_raw_file_path, index_col=0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3d9a9ab4-4240-4824-997b-8bfdb640381c", + "metadata": {}, + "outputs": [], + "source": [ + "# optional - take a look at the data \n", + "data.head(5)" + ] + }, + { + "cell_type": "markdown", + "id": "85cb6dff", + "metadata": {}, + "source": [ + "### Findings\n", + "* Nominal categorical fields - 'cc_num', 'merchant', 'category', 'first', 'last', 'street', 'city', 'state', 'zip', 'job', 'trans_num'\n", + "* Numerical fields - 'amt', 'lat', 'long', 'city_pop', 'merch_lat', 'merch_long'\n", + "* Timestamp fields - 'dob', 'trans_date_trans_time', 'unix_time'\n", + "* Target label - 'is_fraud'\n" + ] + }, + { + "cell_type": "markdown", + "id": "18c3ed53", + "metadata": {}, + "source": [ + "#### How many transactions are fraud?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1e3a9ff7", + "metadata": {}, + "outputs": [], + "source": [ + "data['is_fraud'].value_counts()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8d74fa10", + "metadata": {}, + "outputs": [], + "source": [ + "# Percentage of fraud transactions\n", + "100.0*(data['is_fraud'] == 1).sum()/len(data)" + ] + }, + { + "cell_type": "markdown", + "id": "1bd28023", + "metadata": {}, + "source": [ + "##### Findings - The dataset is extremely imbalanced, only 0.58% of the transactions are fraud" + ] + }, + { + "cell_type": "markdown", + "id": "c39b1a60", + "metadata": {}, + "source": [ + "#### Check if are there Null values in the data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bc2154e5", + "metadata": {}, + "outputs": [], + "source": [ + "# Check if any column has missing values\n", + "data.isnull().sum()\n" + ] + }, + { + "cell_type": "markdown", + "id": "55cbbd84", + "metadata": {}, + "source": [ + "###### Great, none of the columns have null values" + ] + }, + { + "cell_type": "markdown", + "id": "7bf29e24", + "metadata": {}, + "source": [ + "##### Save a few transactions before any operations on data" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "92df5856", + "metadata": {}, + "outputs": [], + "source": [ + "# Write a few raw transactions for model's inference notebook\n", + "out_path = os.path.join(sparkov_xgb, 'example_transactions.csv')\n", + "data.tail(10).to_pandas().to_csv(out_path, header=True, index=False)" + ] + }, + { + "cell_type": "markdown", + "id": "3aa7fc88", + "metadata": {}, + "source": [ + "#### Convert 'dob' to 'age' w.r.t. a reference date" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "c4df4100", + "metadata": {}, + "outputs": [], + "source": [ + "data['dob'] = cudf.to_datetime(data['dob'])" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "4b55a1db", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "one_nanosecond = np.timedelta64(1, 'ns')\n", + "nanoseconds_in_year = 365.25 * 24 * 60 * 60 * 1e9\n", + "reference_date = cudf.to_datetime('2024-10-30') " + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "034210a4", + "metadata": {}, + "outputs": [], + "source": [ + "data['age'] = data['dob'].apply(lambda dob: (reference_date - dob)/ one_nanosecond / nanoseconds_in_year )" + ] + }, + { + "cell_type": "markdown", + "id": "6a72a46a", + "metadata": {}, + "source": [ + "#### Split transaction time in year, month, day and time where time indicate number of minutes" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "3bc3ec47", + "metadata": {}, + "outputs": [], + "source": [ + "tx_date_time = cudf.to_datetime(data.trans_date_trans_time)" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "a0f61401", + "metadata": {}, + "outputs": [], + "source": [ + "data['year'] = tx_date_time.dt.year\n", + "data['month'] = tx_date_time.dt.month\n", + "data['day'] = tx_date_time.dt.day\n", + "data['time'] = tx_date_time.dt.hour*60 + tx_date_time.dt.minute\n" + ] + }, + { + "cell_type": "markdown", + "id": "21f372c0", + "metadata": {}, + "source": [ + "##### Observations\n", + "\n", + "* we can treat 'year', 'month', 'day' as ordinal fields and time as numerical field" + ] + }, + { + "cell_type": "markdown", + "id": "6bb202e2", + "metadata": {}, + "source": [ + "### From ('lat', 'long'), ('merchant_lat', 'merchant_long') and unix_time compute transaction speed" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "90714a56", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "temp_df = pd.DataFrame()\n", + "\n", + "# Haversine formula function\n", + "def haversine(lat1, lon1, lat2, lon2):\n", + " # Radius of Earth in km\n", + " R = 6371.0\n", + "\n", + " # Convert degrees to radians\n", + " lat1 = math.radians(lat1)\n", + " lon1 = math.radians(lon1)\n", + " lat2 = math.radians(lat2)\n", + " lon2 = math.radians(lon2)\n", + "\n", + " # Differences in coordinates\n", + " dlat = lat2 - lat1\n", + " dlon = lon2 - lon1\n", + "\n", + " # Haversine formula\n", + " a = math.sin(dlat / 2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2)**2\n", + " c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))\n", + "\n", + " # Distance in kilometers\n", + " distance = R * c\n", + " return distance\n", + "\n", + "temp_df= data[['unix_time', 'lat', 'long', 'merch_lat', 'merch_long']].to_pandas()\n", + "temp_df['tx_duration'] = temp_df['unix_time'].apply(lambda x: x/1e9)\n", + "temp_df['distance_km'] = temp_df.apply(\n", + " lambda row: haversine(row['lat'], row['long'], row['merch_lat'], row['merch_long']), axis=1)\n", + "data['speed'] = (temp_df['distance_km']/temp_df['tx_duration'])\n", + "del temp_df" + ] + }, + { + "cell_type": "markdown", + "id": "2a5805e8", + "metadata": {}, + "source": [ + "#### Using variables for makes code cleaner" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "c933e5e8", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "COL_CARD = 'cc_num'\n", + "COL_MCC = 'category'\n", + "COL_MERCHANT = 'merchant'\n", + "COL_STATE ='state'\n", + "COL_CITY ='city'\n", + "COL_ZIP = 'zip'\n", + "\n", + "COL_AMOUNT = 'amt'\n", + "COL_CITY_POP = 'city_pop'\n", + "\n", + "COL_FRAUD = 'is_fraud'\n", + "\n", + "COL_TIME = 'time'\n", + "COL_DAY = 'day'\n", + "COL_MONTH = 'month'\n", + "COL_YEAR = 'year'\n", + "COL_AGE = 'age'\n", + "COL_JOB = 'job'\n", + "COL_SPEED = 'speed'\n", + "\n", + "NUMERICAL_COLUMNS = [\n", + " COL_AMOUNT, COL_CITY_POP, COL_TIME, COL_AGE, COL_SPEED,\n", + " 'lat', 'long', 'merch_lat', 'merch_long' ]\n" + ] + }, + { + "cell_type": "markdown", + "id": "128c578e", + "metadata": {}, + "source": [ + "##### Number of cards per user" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a8435e75", + "metadata": {}, + "outputs": [], + "source": [ + "len(data.cc_num.unique()) / len((data['first'] + data['last']).unique())" + ] + }, + { + "cell_type": "markdown", + "id": "1a9c8002", + "metadata": {}, + "source": [ + "#### Look into numerical columns" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "924c30fd", + "metadata": {}, + "outputs": [], + "source": [ + "data[NUMERICAL_COLUMNS].describe()" + ] + }, + { + "cell_type": "markdown", + "id": "73172495", + "metadata": {}, + "source": [ + "#### Findings\n", + "* 'amt' and 'city_pop' have extreme values or outliers compared to mean and median." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "95ae0373", + "metadata": {}, + "outputs": [], + "source": [ + "data[COL_AMOUNT].describe()" + ] + }, + { + "cell_type": "markdown", + "id": "c92cd79e", + "metadata": {}, + "source": [ + "##### Plot histogram of the 'amt' field" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "06f1809a", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "import matplotlib.pyplot as plt\n", + "kde = gaussian_kde(data[COL_AMOUNT].to_pandas())\n", + "x_vals = np.linspace(data[COL_AMOUNT].min(), 2000, 100)\n", + "plt.plot(x_vals, kde(x_vals), color='blue')" + ] + }, + { + "cell_type": "markdown", + "id": "867ea0d4", + "metadata": {}, + "source": [ + "##### Findings\n", + "* very few transactions have higher 'amt' values" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f2322497", + "metadata": {}, + "outputs": [], + "source": [ + "data[COL_CITY_POP].describe()" + ] + }, + { + "cell_type": "markdown", + "id": "01865293", + "metadata": {}, + "source": [ + "##### Plot histogram of the 'city_pop' field" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c8f5c519", + "metadata": {}, + "outputs": [], + "source": [ + "kde = gaussian_kde(data[COL_CITY_POP].to_pandas())\n", + "x_vals = np.linspace(data[COL_CITY_POP].min(), 100000, 100)\n", + "plt.plot(x_vals, kde(x_vals), color='blue')" + ] + }, + { + "cell_type": "markdown", + "id": "b3d3941c", + "metadata": {}, + "source": [ + "##### Findings\n", + "* Only a few cities have a population over 40,000" + ] + }, + { + "cell_type": "markdown", + "id": "ab82a9f9", + "metadata": {}, + "source": [ + "#### Let's look into how the amount differ between fraud and non-fraud transactions" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "31679a1d", + "metadata": {}, + "outputs": [], + "source": [ + "data[COL_AMOUNT].describe()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "66de8459", + "metadata": {}, + "outputs": [], + "source": [ + "# Fraud transactions\n", + "data[COL_AMOUNT][data[COL_FRAUD] == 1].describe()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0f49ff49", + "metadata": {}, + "outputs": [], + "source": [ + "# Non-fraud transactions\n", + "data[COL_AMOUNT][data[COL_FRAUD] == 0].describe()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1151a313", + "metadata": {}, + "outputs": [], + "source": [ + "# Non-fraud transactions with high value of amount \n", + "data[COL_AMOUNT] [ (data[COL_FRAUD]==0) & (data[COL_AMOUNT] > 1376) ].describe()" + ] + }, + { + "cell_type": "markdown", + "id": "a190bb9e", + "metadata": {}, + "source": [ + "#### Findings\n", + "\n", + "* Average amount in fraud transactions > 8x the average amount in non-fraud transactions\n", + "* Interestingly, many non-fraud transactions have high amount as well.\n", + "\n", + "We need to scale the data, and RobustScaler could be a good choice for it." + ] + }, + { + "cell_type": "markdown", + "id": "54f56d5a-f135-4af2-ba13-b926f66a045f", + "metadata": {}, + "source": [ + "#### Number of unique values per nominal columns" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2dff40cb", + "metadata": {}, + "outputs": [], + "source": [ + "# Check how many unique values for \n", + "for col in [COL_STATE, COL_CITY, COL_ZIP, COL_MERCHANT, COL_MCC, COL_CARD]:\n", + " print(f'#unique values ({col}) = {len(data[col].unique())}')\n" + ] + }, + { + "cell_type": "markdown", + "id": "86ca593a", + "metadata": {}, + "source": [ + "#### Findings\n", + "We can binary encode 'state', 'city', 'zip', 'merchant', 'category', 'cc_num', if the columns have good correlation with targets" + ] + }, + { + "cell_type": "markdown", + "id": "50933790-780c-43cc-833d-c7ad16acbde3", + "metadata": {}, + "source": [ + "#### Take a look into distribution of 'time', 'speed' and 'age' columns\n" + ] + }, + { + "cell_type": "markdown", + "id": "691a355b", + "metadata": {}, + "source": [ + "##### Plot histogram of transaction 'speed'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d490cd45", + "metadata": {}, + "outputs": [], + "source": [ + "kde = gaussian_kde(data[COL_SPEED].to_pandas())\n", + "x_vals = np.linspace(data[COL_SPEED].min(), data[COL_SPEED].max(), 100)\n", + "plt.plot(x_vals, kde(x_vals), color='blue')" + ] + }, + { + "cell_type": "markdown", + "id": "00e1d50f", + "metadata": {}, + "source": [ + "##### Plot histogram of 'time'\n", + "__NOTE__ Time is captured as number of minutes over the span of a day" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8cd994bf", + "metadata": {}, + "outputs": [], + "source": [ + "kde = gaussian_kde(data[COL_TIME].to_pandas())\n", + "x_vals = np.linspace(data[COL_TIME].min(), data[COL_TIME].max(), 100)\n", + "plt.plot(x_vals, kde(x_vals), color='blue')" + ] + }, + { + "cell_type": "markdown", + "id": "97873360", + "metadata": {}, + "source": [ + "##### Plot histogram of 'age'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "576fb419", + "metadata": {}, + "outputs": [], + "source": [ + "kde = gaussian_kde(data[COL_AGE].to_pandas())\n", + "x_vals = np.linspace(data[COL_AGE].min(), data[COL_AGE].max(), 100)\n", + "plt.plot(x_vals, kde(x_vals), color='blue')" + ] + }, + { + "cell_type": "markdown", + "id": "f75c7c59", + "metadata": {}, + "source": [ + "##### Findings\n", + "* It's not obvious from the histogram of 'time,' 'speed,' and 'age' whether they are clear indicators for labeling a transaction as fraud." + ] + }, + { + "cell_type": "markdown", + "id": "5a815bc9", + "metadata": {}, + "source": [ + "#### Define a function to compute correlation of different categorical fields with target" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "id": "cfaa31fd", + "metadata": {}, + "outputs": [], + "source": [ + "# https://en.wikipedia.org/wiki/Cram%C3%A9r's_V\n", + "\n", + "def cramers_v(x, y):\n", + " confusion_matrix = cudf.crosstab(x, y).to_numpy()\n", + " chi2 = ss.chi2_contingency(confusion_matrix)[0]\n", + " n = confusion_matrix.sum().sum()\n", + " r, k = confusion_matrix.shape\n", + " return np.sqrt(chi2 / (n * (min(k-1, r-1))))" + ] + }, + { + "cell_type": "markdown", + "id": "1fa39773", + "metadata": {}, + "source": [ + "##### Compute correlation of different field with target" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e5497f70", + "metadata": {}, + "outputs": [], + "source": [ + "sparse_factor = 1\n", + "columns_to_compute_corr = [\n", + " COL_CARD, COL_STATE, COL_CITY, COL_ZIP, COL_MCC, COL_MERCHANT,\n", + " COL_DAY, COL_MONTH, COL_YEAR, COL_JOB, 'gender']\n", + "for c1 in columns_to_compute_corr:\n", + " for c2 in [COL_FRAUD]:\n", + " coff = 100 * cramers_v(data[c1][::sparse_factor], data[c2][::sparse_factor])\n", + " print('Correlation ({}, {}) = {:6.2f}%'.format(c1, c2, coff))" + ] + }, + { + "cell_type": "markdown", + "id": "738cd723", + "metadata": {}, + "source": [ + "#### Findings\n", + "* 'day', 'month', and 'year' 'gender' are not important to predict if a transaction is fraud or not" + ] + }, + { + "cell_type": "markdown", + "id": "00296660", + "metadata": {}, + "source": [ + "#### Check how City, State and Zip are correlated" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6ac25d40", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "sparse_factor = 1\n", + "columns_to_compute_corr = [COL_STATE, COL_CITY, COL_ZIP]\n", + "for c1 in columns_to_compute_corr:\n", + " for c2 in columns_to_compute_corr:\n", + " if c1 not in c2:\n", + " coff = 100 * cramers_v(data[c1][::sparse_factor], data[c2][::sparse_factor])\n", + " print('{} {} {:6.2f}%'.format(c1, c2, coff))" + ] + }, + { + "cell_type": "markdown", + "id": "e2b1edd3", + "metadata": {}, + "source": [ + "#### Findings\n", + "* if we use 'zip' to predict if a transaction is fraud or not, we don't need to use 'city' and 'state'" + ] + }, + { + "cell_type": "markdown", + "id": "a50d67c2", + "metadata": {}, + "source": [ + "### Correlation of target with numerical columns" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "38353361", + "metadata": {}, + "outputs": [], + "source": [ + "# https://en.wikipedia.org/wiki/Point-biserial_correlation_coefficient\n", + "# Use Point-biserial correlation coefficient(rpb) to check if the numerical columns are important to predict if a transaction is fraud\n", + "\n", + "for col in NUMERICAL_COLUMNS:\n", + " r_pb, p_value = pointbiserialr(data[COL_FRAUD].to_pandas(), data[col].to_pandas())\n", + " print('r_pb ({}) = {:3.2f} with p_value {:3.2f}'.format(col, r_pb, p_value))" + ] + }, + { + "cell_type": "markdown", + "id": "400c8f83", + "metadata": {}, + "source": [ + "#### Findings\n", + "* 'amt' column has positive correlation with target\n", + "* other columns, such as 'city_pop', 'time', 'age', 'lat', 'long', 'merch_lat', and 'merch_long' has negligible correlation with target\n", + "* Speed can't be ignored as the p_value > 0.05" + ] + }, + { + "cell_type": "markdown", + "id": "f92d58ef", + "metadata": {}, + "source": [ + "#### Based on correlation values, select a set of columns (aka fields) to predict whether a transaction is fraud" + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "id": "a00a7813", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "numerical_predictors = [COL_AMOUNT, COL_SPEED, COL_AGE]\n", + "nominal_predictors = [COL_CARD, COL_ZIP, COL_MCC, COL_MERCHANT, COL_JOB]\n", + "\n", + "predictor_columns = numerical_predictors + nominal_predictors\n", + "\n", + "target_column = [COL_FRAUD]" + ] + }, + { + "cell_type": "markdown", + "id": "9341f5e0", + "metadata": {}, + "source": [ + "#### Remove duplicates non-fraud data points" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "428c4ac8", + "metadata": {}, + "outputs": [], + "source": [ + "# Remove duplicates data points\n", + "fraud_data = data[data[COL_FRAUD] == 1]\n", + "data = data[data[COL_FRAUD] == 0]\n", + "data = data.drop_duplicates(subset=nominal_predictors)\n", + "data = cudf.concat([data, fraud_data])\n", + "\n", + "100*data[COL_FRAUD].value_counts()/len(data)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4fe02370", + "metadata": {}, + "outputs": [], + "source": [ + "# Portion of fraud and non-fraud cases\n", + "data[COL_YEAR].value_counts()/len(data)" + ] + }, + { + "cell_type": "markdown", + "id": "8bc2ebac", + "metadata": {}, + "source": [ + "### Split data\n", + "All the transactions were made in year 2019. Let's split the data into three groups based on event month\n", + "* Training - all transactions in 2019\n", + "* Validation - all transactions between January and May in 2020\n", + "* Test. - all transactions after May 2020" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "97dc748b", + "metadata": {}, + "outputs": [], + "source": [ + "if under_sample: \n", + " fraud_df = data[data[COL_FRAUD]==1]\n", + " non_fraud_df = data[data[COL_FRAUD]==0]\n", + " nr_non_fraud_samples = min((len(data) - len(fraud_df)), int(len(fraud_df)/fraud_ratio))\n", + " data = cudf.concat([fraud_df, non_fraud_df.sample(nr_non_fraud_samples)])\n", + "\n", + "training_idx = data[COL_YEAR] == 2019\n", + "validation_idx = (data[COL_YEAR] == 2020) & (data[COL_MONTH] < 4 )\n", + "test_idx = (data[COL_YEAR] == 2020) & (data[COL_MONTH] >= 4 )\n", + "\n", + "data[COL_FRAUD].value_counts()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9a7d2f0c", + "metadata": {}, + "outputs": [], + "source": [ + "# portion of data for training, test and validation\n", + "training_idx.sum()/len(data), validation_idx.sum()/len(data), test_idx.sum()/len(data)" + ] + }, + { + "cell_type": "markdown", + "id": "cd036929", + "metadata": {}, + "source": [ + "### Scale numerical columns and encode categorical columns of training data" + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "id": "2d570cdc", + "metadata": {}, + "outputs": [], + "source": [ + "# As some of the encoder we want to use is not available in cuml yet, we can use pandas for now.\n", + "# Move training data to pandas for preprocessing\n", + "pdf_training = data[training_idx].to_pandas()[predictor_columns + target_column]" + ] + }, + { + "cell_type": "code", + "execution_count": 41, + "id": "d135cc8a", + "metadata": {}, + "outputs": [], + "source": [ + "#Use binary encoding for categorical columns\n", + "columns_for_binary_encoding = nominal_predictors" + ] + }, + { + "cell_type": "code", + "execution_count": 42, + "id": "ee6b77fc", + "metadata": {}, + "outputs": [], + "source": [ + "# Mark categorical column as \"category\"\n", + "pdf_training[nominal_predictors] = pdf_training[nominal_predictors].astype(\"category\")" + ] + }, + { + "cell_type": "code", + "execution_count": 43, + "id": "6d20de67", + "metadata": {}, + "outputs": [], + "source": [ + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 43, + "id": "49eb57f5", + "metadata": {}, + "outputs": [], + "source": [ + "# encoders to encode categorical columns and scalers to scale numerical columns\n", + "\n", + "bin_encoder = Pipeline(\n", + " steps=[\n", + " (\"binary\", BinaryEncoder(handle_missing='value', handle_unknown='value'))\n", + " ]\n", + ")\n", + "onehot_encoder = Pipeline(\n", + " steps=[\n", + " (\"onehot\", OneHotEncoder())\n", + " ]\n", + ")\n", + "\n", + "std_scaler = Pipeline(\n", + " steps=[(\"imputer\", SimpleImputer(strategy=\"median\")), (\"standard\", StandardScaler())],\n", + ")\n", + "\n", + "robust_scaler = Pipeline(\n", + " steps=[(\"imputer\", SimpleImputer(strategy=\"median\")), (\"robust\", RobustScaler())],\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 44, + "id": "613db861", + "metadata": {}, + "outputs": [], + "source": [ + "# compose encoders and scalers in a column transformer\n", + "transformer = ColumnTransformer(\n", + " transformers=[\n", + " (\"binary\", bin_encoder, columns_for_binary_encoding ), \n", + " (\"robust\", robust_scaler, [COL_AMOUNT]),\n", + " (\"stdscaler\", std_scaler, [COL_SPEED, COL_AGE]),\n", + " ], remainder=\"passthrough\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "79be6b2b", + "metadata": {}, + "source": [ + "##### Fit column transformer with training data" + ] + }, + { + "cell_type": "code", + "execution_count": 45, + "id": "ba373e41", + "metadata": {}, + "outputs": [], + "source": [ + "# Fit column transformer with training data\n", + "\n", + "pd.set_option('future.no_silent_downcasting', True)\n", + "transformer = transformer.fit(pdf_training[predictor_columns])" + ] + }, + { + "cell_type": "code", + "execution_count": 46, + "id": "9f10f84a", + "metadata": {}, + "outputs": [], + "source": [ + "# transformed column names\n", + "columns_of_transformed_data = list(\n", + " map(lambda name: name.split('__')[1],\n", + " list(transformer.get_feature_names_out(predictor_columns))))" + ] + }, + { + "cell_type": "code", + "execution_count": 47, + "id": "739f62a4", + "metadata": {}, + "outputs": [], + "source": [ + "# data type of transformed columns \n", + "type_mapping = {}\n", + "for col in columns_of_transformed_data:\n", + " if col.split('_')[0] in nominal_predictors:\n", + " type_mapping[col] = 'int8'\n", + " elif col in numerical_predictors:\n", + " type_mapping[col] = 'float'\n", + " elif col in target_column:\n", + " type_mapping[col] = data.dtypes.to_dict()[col]" + ] + }, + { + "cell_type": "code", + "execution_count": 48, + "id": "b5471b25", + "metadata": {}, + "outputs": [], + "source": [ + "# transform training data\n", + "preprocessed_training_data = transformer.transform(pdf_training[predictor_columns])\n", + "\n", + "# Convert transformed data to panda DataFrame\n", + "preprocessed_training_data = pd.DataFrame(\n", + " preprocessed_training_data, columns=columns_of_transformed_data)\n", + "# Copy target column\n", + "preprocessed_training_data[COL_FRAUD] = pdf_training[COL_FRAUD].values\n", + "preprocessed_training_data = preprocessed_training_data.astype(type_mapping)" + ] + }, + { + "cell_type": "code", + "execution_count": 49, + "id": "f02184b2", + "metadata": {}, + "outputs": [], + "source": [ + "# Save the transformer \n", + "\n", + "with open(os.path.join(sparkov_base_path, 'preprocessor.pkl'),'wb') as f:\n", + " pickle.dump(transformer, f)" + ] + }, + { + "cell_type": "markdown", + "id": "8f3de882", + "metadata": {}, + "source": [ + "#### Save transformed training data for XGBoost training" + ] + }, + { + "cell_type": "code", + "execution_count": 50, + "id": "e3362673", + "metadata": {}, + "outputs": [], + "source": [ + "with open(os.path.join(sparkov_base_path, 'preprocessor.pkl'),'rb') as f:\n", + " loaded_transformer = pickle.load(f)" + ] + }, + { + "cell_type": "code", + "execution_count": 51, + "id": "47f6663e", + "metadata": {}, + "outputs": [], + "source": [ + "# Transform test data using the transformer fitted on training data\n", + "pdf_test = data[test_idx].to_pandas()[predictor_columns + target_column]\n", + "pdf_test[nominal_predictors] = pdf_test[nominal_predictors].astype(\"category\")\n", + "\n", + "preprocessed_test_data = loaded_transformer.transform(pdf_test[predictor_columns])\n", + "preprocessed_test_data = pd.DataFrame(preprocessed_test_data, columns=columns_of_transformed_data)\n", + "\n", + "# Copy target column\n", + "preprocessed_test_data[COL_FRAUD] = pdf_test[COL_FRAUD].values\n", + "preprocessed_test_data = preprocessed_test_data.astype(type_mapping)" + ] + }, + { + "cell_type": "code", + "execution_count": 52, + "id": "0ce80a1b", + "metadata": {}, + "outputs": [], + "source": [ + "# Transform validation data using the transformer fitted on training data\n", + "pdf_validation = data[validation_idx].to_pandas()[predictor_columns + target_column]\n", + "pdf_validation[nominal_predictors] = pdf_validation[nominal_predictors].astype(\"category\")\n", + "\n", + "preprocessed_validation_data = loaded_transformer.transform(pdf_validation[predictor_columns])\n", + "preprocessed_validation_data = pd.DataFrame(preprocessed_validation_data, columns=columns_of_transformed_data)\n", + "\n", + "# Copy target column\n", + "preprocessed_validation_data[COL_FRAUD] = pdf_validation[COL_FRAUD].values\n", + "preprocessed_validation_data = preprocessed_validation_data.astype(type_mapping)" + ] + }, + { + "cell_type": "markdown", + "id": "cb2ca66b-d3dc-4f67-9754-b90bbea6e286", + "metadata": {}, + "source": [ + "## Write out the data for XGB" + ] + }, + { + "cell_type": "code", + "execution_count": 53, + "id": "89c16cfb-0bd4-4efb-a610-f3ae7445d96e", + "metadata": {}, + "outputs": [], + "source": [ + "## Training data\n", + "out_path = os.path.join(sparkov_xgb, 'training.csv')\n", + "if not os.path.exists(os.path.dirname(out_path)):\n", + " os.makedirs(os.path.dirname(out_path))\n", + "preprocessed_training_data.to_csv(\n", + " out_path, header=True, index=False, columns=columns_of_transformed_data + target_column)\n", + "# preprocessed_training_data.to_parquet(out_path, index=False, compression='gzip')" + ] + }, + { + "cell_type": "code", + "execution_count": 54, + "id": "f3ef6b19-062b-42d5-8caa-6f6011648b4a", + "metadata": {}, + "outputs": [], + "source": [ + "## validation data\n", + "out_path = os.path.join(sparkov_xgb, 'validation.csv')\n", + "if not os.path.exists(os.path.dirname(out_path)):\n", + " os.makedirs(os.path.dirname(out_path))\n", + "preprocessed_validation_data.to_csv(\n", + " out_path, header=True, index=False, columns=columns_of_transformed_data + target_column)\n", + "# preprocessed_validation_data.to_parquet(out_path, index=False, compression='gzip')" + ] + }, + { + "cell_type": "code", + "execution_count": 55, + "id": "cdc8e3b9-841d-49f3-b3ae-3017a04605e3", + "metadata": {}, + "outputs": [], + "source": [ + "## test data\n", + "out_path = os.path.join(sparkov_xgb, 'test.csv')\n", + "preprocessed_test_data.to_csv(\n", + " out_path, header=True, index=False, columns=columns_of_transformed_data + target_column)\n", + "# preprocessed_test_data.to_parquet(out_path, index=False, compression='gzip')" + ] + }, + { + "cell_type": "code", + "execution_count": 56, + "id": "67fb32b0", + "metadata": {}, + "outputs": [], + "source": [ + "# Write untransformed test data that has only (renamed) predictor columns and target\n", + "out_path = os.path.join(sparkov_xgb, 'untransformed_test.csv')\n", + "pdf_test.to_csv(out_path, header=True, index=False)" + ] + }, + { + "cell_type": "code", + "execution_count": 57, + "id": "2d6cb604", + "metadata": {}, + "outputs": [], + "source": [ + "# Delete dataFrames that are not needed anymore\n", + "del(pdf_training)\n", + "del(pdf_validation)\n", + "del(pdf_test)\n", + "del(preprocessed_training_data)\n", + "del(preprocessed_validation_data)\n", + "del(preprocessed_test_data)" + ] + }, + { + "cell_type": "markdown", + "id": "3bfbfd83", + "metadata": {}, + "source": [ + "### GNN Data" + ] + }, + { + "cell_type": "markdown", + "id": "98e518c8", + "metadata": {}, + "source": [ + "#### Setting Vertex IDs\n", + "In order to create a graph, the different vertices need to be assigned unique vertex IDs. Additionally, the IDs needs to be consecutive and positive.\n", + "\n", + "There are three nodes groups here: Transactions, Users, and Merchants. \n", + "\n", + "These IDs are not used in training, just used for graph processing." + ] + }, + { + "cell_type": "code", + "execution_count": 58, + "id": "194a47d8", + "metadata": {}, + "outputs": [], + "source": [ + "# Use the same training data as used for XGBoost\n", + "data = data[training_idx]" + ] + }, + { + "cell_type": "code", + "execution_count": 59, + "id": "0ba0cb6b", + "metadata": {}, + "outputs": [], + "source": [ + "# a lot of process has occurred, sort the data and reset the index\n", + "data = data.sort_values(by=[COL_YEAR, COL_MONTH, COL_DAY, COL_TIME], ascending=False)\n", + "data.reset_index(inplace=True, drop=True)" + ] + }, + { + "cell_type": "code", + "execution_count": 60, + "id": "2a75c92e", + "metadata": {}, + "outputs": [], + "source": [ + "# Each transaction gets a unique ID\n", + "COL_TRANSACTION_ID = 'Tx_ID'\n", + "COL_MERCHANT_ID = 'Merchant_ID'\n", + "COL_USER_ID = 'User_ID'\n", + "\n", + "# The number of transaction is the same as the size of the list, and hence the index value\n", + "data[COL_TRANSACTION_ID] = data.index" + ] + }, + { + "cell_type": "code", + "execution_count": 61, + "id": "472ea57c", + "metadata": {}, + "outputs": [], + "source": [ + "# Get the max transaction ID to compute first merchant ID\n", + "max_tx_id = data[COL_TRANSACTION_ID].max()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7e8ef04b", + "metadata": {}, + "outputs": [], + "source": [ + "# Convert Merchant string to consecutive integers\n", + "merchant_name_to_id = dict((v, k) for k, v in data[COL_MERCHANT].unique().to_dict().items())\n", + "data[COL_MERCHANT_ID] = data[COL_MERCHANT].map(merchant_name_to_id) + (max_tx_id + 1)\n", + "data[COL_MERCHANT_ID].min(), data[COL_MERCHANT_ID].max()" + ] + }, + { + "cell_type": "code", + "execution_count": 63, + "id": "6937df18", + "metadata": {}, + "outputs": [], + "source": [ + "# Again, get the max merchant ID to compute first user ID\n", + "max_merchant_id = data[COL_MERCHANT_ID].max()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c63c7fc3", + "metadata": {}, + "outputs": [], + "source": [ + "# Convert Card to consecutive user IDs\n", + "user_id_to_consecutive_ids = dict((v, k) for k, v in data[COL_CARD].unique().to_dict().items())\n", + "data[COL_USER_ID] = data[COL_CARD].map(user_id_to_consecutive_ids) + max_merchant_id + 1\n", + "data[COL_USER_ID].min(), data[COL_USER_ID].max()\n" + ] + }, + { + "cell_type": "code", + "execution_count": 65, + "id": "28858422", + "metadata": {}, + "outputs": [], + "source": [ + "# Save the max user ID\n", + "max_user_id = data[COL_USER_ID].max()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "903e5115", + "metadata": {}, + "outputs": [], + "source": [ + "# Check the the transaction, merchant and user ids are consecutive\n", + "id_range = data[COL_TRANSACTION_ID].min(), data[COL_TRANSACTION_ID].max()\n", + "print(f'Transaction ID range {id_range}')\n", + "id_range = data[COL_MERCHANT_ID].min(), data[COL_MERCHANT_ID].max()\n", + "print(f'Merchant ID range {id_range}')\n", + "id_range = data[COL_USER_ID].min(), data[COL_USER_ID].max()\n", + "print(f'User ID range {id_range}')" + ] + }, + { + "cell_type": "code", + "execution_count": 67, + "id": "f2d0dfde", + "metadata": {}, + "outputs": [], + "source": [ + "# Sanity checks\n", + "assert( data[COL_TRANSACTION_ID].max() == data[COL_MERCHANT_ID].min() - 1)\n", + "assert( data[COL_MERCHANT_ID].max() == data[COL_USER_ID].min() - 1)\n", + "assert(len(data[COL_USER_ID].unique()) == (data[COL_USER_ID].max() - data[COL_USER_ID].min() + 1))\n", + "assert(len(data[COL_MERCHANT_ID].unique()) == (data[COL_MERCHANT_ID].max() - data[COL_MERCHANT_ID].min() + 1))\n", + "assert(len(data[COL_TRANSACTION_ID].unique()) == (data[COL_TRANSACTION_ID].max() - data[COL_TRANSACTION_ID].min() + 1))" + ] + }, + { + "cell_type": "markdown", + "id": "0d9c3df3-a5be-4899-8bf9-6152aca114c7", + "metadata": {}, + "source": [ + "### Write out the data for GNN" + ] + }, + { + "cell_type": "markdown", + "id": "c2b86862-d129-4ece-a60d-dc798f3a68b5", + "metadata": {}, + "source": [ + "#### Create the Graph Edge Data file \n", + "The file is in COO format" + ] + }, + { + "cell_type": "code", + "execution_count": 68, + "id": "b288c5a7-20dd-40ff-b0eb-7a5895bcc464", + "metadata": {}, + "outputs": [], + "source": [ + "COL_GRAPH_SRC = 'src'\n", + "COL_GRAPH_DST = 'dst'\n", + "COL_GRAPH_WEIGHT = 'wgt'\n", + "\n", + "# User to Transactions\n", + "U_2_T = cudf.DataFrame()\n", + "U_2_T[COL_GRAPH_SRC] = data[COL_USER_ID]\n", + "U_2_T[COL_GRAPH_DST] = data[COL_TRANSACTION_ID]\n", + "if make_undirected:\n", + " T_2_U = cudf.DataFrame()\n", + " T_2_U[COL_GRAPH_SRC] = data[COL_TRANSACTION_ID]\n", + " T_2_U[COL_GRAPH_DST] = data[COL_USER_ID]\n", + " U_2_T = cudf.concat([U_2_T, T_2_U])\n", + " del T_2_U\n" + ] + }, + { + "cell_type": "code", + "execution_count": 69, + "id": "a970747d-07a2-43b3-b39c-19a0196fa5b1", + "metadata": {}, + "outputs": [], + "source": [ + "# Transactions to Merchants\n", + "T_2_M = cudf.DataFrame()\n", + "T_2_M[COL_GRAPH_SRC] = data[COL_MERCHANT_ID]\n", + "T_2_M[COL_GRAPH_DST] = data[COL_TRANSACTION_ID]\n", + "\n", + "if make_undirected:\n", + " M_2_T = cudf.DataFrame()\n", + " M_2_T[COL_GRAPH_SRC] = data[COL_TRANSACTION_ID]\n", + " M_2_T[COL_GRAPH_DST] = data[COL_MERCHANT_ID]\n", + " T_2_M = cudf.concat([T_2_M, M_2_T])\n", + " del M_2_T" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "80e704fd-ae9f-45b1-ad56-bdc0b743d09f", + "metadata": {}, + "outputs": [], + "source": [ + "Edge = cudf.concat([U_2_T, T_2_M])\n", + "Edge[COL_GRAPH_WEIGHT] = 0.0\n", + "len(Edge)" + ] + }, + { + "cell_type": "code", + "execution_count": 71, + "id": "c74572f6-ff6e-4c8f-803e-0ae2c0587c58", + "metadata": {}, + "outputs": [], + "source": [ + "# now write out the data\n", + "out_path = os.path.join (sparkov_gnn, 'edges.csv')\n", + "\n", + "if not os.path.exists(os.path.dirname(out_path)):\n", + " os.makedirs(os.path.dirname(out_path))\n", + " \n", + "Edge.to_csv(out_path, header=False, index=False)" + ] + }, + { + "cell_type": "code", + "execution_count": 72, + "id": "3dd3ff45-3796-4069-9e3a-587743c4e1e0", + "metadata": {}, + "outputs": [], + "source": [ + "del(Edge)\n", + "del(U_2_T)\n", + "del(T_2_M)" + ] + }, + { + "cell_type": "markdown", + "id": "ed00c481-1737-4152-9d23-f3cb24f2adcd", + "metadata": {}, + "source": [ + "### Now the feature data\n", + "Feature data needs to be is sorted in order, where the row index corresponds to the node ID\n", + "\n", + "The data is comprised of three sets of features\n", + "* Transactions\n", + "* Users\n", + "* Merchants" + ] + }, + { + "cell_type": "markdown", + "id": "805c9d23", + "metadata": {}, + "source": [ + "#### To get feature vectors of Transaction nodes, transform the training data using pre-fitted transformer" + ] + }, + { + "cell_type": "code", + "execution_count": 73, + "id": "584fe9bf", + "metadata": {}, + "outputs": [], + "source": [ + "node_feature_df = pd.DataFrame(\n", + " loaded_transformer.transform(\n", + " data[predictor_columns].to_pandas()\n", + " ),\n", + " columns=columns_of_transformed_data).astype(type_mapping)\n", + "\n", + "node_feature_df[COL_FRAUD] = data[COL_FRAUD].to_pandas()" + ] + }, + { + "cell_type": "markdown", + "id": "55aa8f86", + "metadata": {}, + "source": [ + "#### For graph nodes associated with merchant and user, add feature vectors of zeros" + ] + }, + { + "cell_type": "code", + "execution_count": 74, + "id": "b35f9f5b", + "metadata": {}, + "outputs": [], + "source": [ + "# Number of graph nodes for users and merchants \n", + "nr_users_and_merchant_nodes = max_user_id - max_tx_id" + ] + }, + { + "cell_type": "code", + "execution_count": 75, + "id": "b5d312bd", + "metadata": {}, + "outputs": [], + "source": [ + "if not spread_features:\n", + " # Create feature vector of all zeros for each user and merchant node\n", + " empty_feature_df = cudf.DataFrame(\n", + " columns=columns_of_transformed_data + target_column,\n", + " dtype='int8', \n", + " index=range(nr_users_and_merchant_nodes)\n", + " )\n", + " empty_feature_df = empty_feature_df.fillna(0)\n", + " empty_feature_df=empty_feature_df.astype(type_mapping)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 76, + "id": "a72d3ea5-e04f-4af1-a0e0-09964555c1ed", + "metadata": {}, + "outputs": [], + "source": [ + "if not spread_features:\n", + " # Concatenate transaction features followed by features for merchants and user nodes\n", + " node_feature_df = pd.concat([node_feature_df, empty_feature_df.to_pandas()]).astype(type_mapping)" + ] + }, + { + "cell_type": "code", + "execution_count": 77, + "id": "a364d173", + "metadata": {}, + "outputs": [], + "source": [ + "# User specific columns\n", + "if spread_features:\n", + " user_specific_columns = [COL_CARD]\n", + " user_specific_columns_of_transformed_data = []\n", + "\n", + " for col in node_feature_df.columns:\n", + " if '_'.join(col.split('_')[:-1]) in user_specific_columns:\n", + " user_specific_columns_of_transformed_data.append(col)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 78, + "id": "92d88c2f", + "metadata": {}, + "outputs": [], + "source": [ + "# Merchant specific columns\n", + "if spread_features:\n", + " merchant_specific_columns = [COL_MERCHANT, COL_CITY, COL_ZIP, COL_MCC]\n", + " merchant_specific_columns_of_transformed_data = []\n", + " \n", + " for col in node_feature_df.columns:\n", + " if col.split('_')[0] in merchant_specific_columns:\n", + " merchant_specific_columns_of_transformed_data.append(col)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 79, + "id": "f62755ae", + "metadata": {}, + "outputs": [], + "source": [ + "# Transaction specific columns\n", + "if spread_features:\n", + " transaction_specific_columns = list(\n", + " set(numerical_predictors).union(nominal_predictors)\n", + " - set(user_specific_columns).union(merchant_specific_columns))\n", + " transaction_specific_columns_of_transformed_data = []\n", + " \n", + " for col in node_feature_df.columns:\n", + " if col.split('_')[0] in transaction_specific_columns:\n", + " transaction_specific_columns_of_transformed_data.append(col) " + ] + }, + { + "cell_type": "markdown", + "id": "d12061da", + "metadata": {}, + "source": [ + "#### Construct feature vector for merchants" + ] + }, + { + "cell_type": "code", + "execution_count": 80, + "id": "de484a27", + "metadata": {}, + "outputs": [], + "source": [ + "if spread_features:\n", + " # Find indices of unique merchants\n", + " idx_df = cudf.DataFrame()\n", + " idx_df[COL_MERCHANT_ID] = data[COL_MERCHANT_ID]\n", + " idx_df = idx_df.sort_values(by=COL_MERCHANT_ID)\n", + " idx_df = idx_df.drop_duplicates(subset=COL_MERCHANT_ID)\n", + " assert((data.iloc[idx_df.index][COL_MERCHANT_ID] == idx_df[COL_MERCHANT_ID]).all())" + ] + }, + { + "cell_type": "code", + "execution_count": 81, + "id": "5be790eb", + "metadata": {}, + "outputs": [], + "source": [ + "if spread_features:\n", + " # Copy merchant specific columns, and set the rest to zero\n", + " merchant_specific_feature_df = node_feature_df.iloc[idx_df.index.to_numpy()]\n", + " merchant_specific_feature_df.\\\n", + " loc[:, \n", + " transaction_specific_columns_of_transformed_data +\n", + " user_specific_columns_of_transformed_data] = 0.0\n" + ] + }, + { + "cell_type": "code", + "execution_count": 82, + "id": "576091c6", + "metadata": {}, + "outputs": [], + "source": [ + "if spread_features:\n", + " # Find indices of unique users\n", + " idx_df = cudf.DataFrame()\n", + " idx_df[COL_USER_ID] = data[COL_USER_ID]\n", + " idx_df = idx_df.sort_values(by=COL_USER_ID)\n", + " idx_df = idx_df.drop_duplicates(subset=COL_USER_ID)\n", + " assert((data.iloc[idx_df.index][COL_USER_ID] == idx_df[COL_USER_ID]).all())" + ] + }, + { + "cell_type": "code", + "execution_count": 83, + "id": "aec23ee5", + "metadata": {}, + "outputs": [], + "source": [ + "if spread_features:\n", + " # Copy user specific columns, and set the rest to zero\n", + " user_specific_feature_df = node_feature_df.iloc[idx_df.index.to_numpy()]\n", + " user_specific_feature_df.\\\n", + " loc[:,\n", + " transaction_specific_columns_of_transformed_data +\n", + " merchant_specific_columns_of_transformed_data] = 0.0 " + ] + }, + { + "cell_type": "code", + "execution_count": 84, + "id": "8296a341", + "metadata": {}, + "outputs": [], + "source": [ + "# Concatenate features of node, user and merchant\n", + "if spread_features:\n", + " \n", + " node_feature_df[merchant_specific_columns_of_transformed_data] = 0.0\n", + " node_feature_df[user_specific_columns_of_transformed_data] = 0.0\n", + " node_feature_df = pd.concat(\n", + " [node_feature_df, merchant_specific_feature_df, user_specific_feature_df]\n", + " ).astype(type_mapping)\n", + " \n", + " # features to save\n", + " node_feature_df = node_feature_df[\n", + " transaction_specific_columns_of_transformed_data +\n", + " merchant_specific_columns_of_transformed_data +\n", + " user_specific_columns_of_transformed_data + [COL_FRAUD]]\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a54aa686", + "metadata": {}, + "outputs": [], + "source": [ + "node_feature_df.columns" + ] + }, + { + "cell_type": "code", + "execution_count": 86, + "id": "527f6ea8", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "# target labels to save\n", + "label_df = node_feature_df[[COL_FRAUD]]" + ] + }, + { + "cell_type": "code", + "execution_count": 87, + "id": "15e1cba8", + "metadata": {}, + "outputs": [], + "source": [ + "# Remove target label from feature vectors\n", + "_ = node_feature_df.drop(columns=[COL_FRAUD], inplace=True)" + ] + }, + { + "cell_type": "markdown", + "id": "310d9500", + "metadata": {}, + "source": [ + "#### Write out node features and target labels" + ] + }, + { + "cell_type": "code", + "execution_count": 88, + "id": "139bfd9f", + "metadata": {}, + "outputs": [], + "source": [ + "# Write node target label to csv file\n", + "out_path = os.path.join(sparkov_gnn, 'labels.csv')\n", + "\n", + "if not os.path.exists(os.path.dirname(out_path)):\n", + " os.makedirs(os.path.dirname(out_path))\n", + "\n", + "label_df.to_csv(out_path, header=False, index=False)\n", + "# label_df.to_parquet(out_path, index=False, compression='gzip')" + ] + }, + { + "cell_type": "code", + "execution_count": 89, + "id": "b8fe801e", + "metadata": {}, + "outputs": [], + "source": [ + "# Write node features to csv file\n", + "out_path = os.path.join(sparkov_gnn, 'features.csv')\n", + "\n", + "if not os.path.exists(os.path.dirname(out_path)):\n", + " os.makedirs(os.path.dirname(out_path))\n", + "node_feature_df[columns_of_transformed_data].to_csv(out_path, header=True, index=False)\n", + "# node_feature_df.to_parquet(out_path, index=False, compression='gzip')" + ] + }, + { + "cell_type": "code", + "execution_count": 90, + "id": "fbe75d91", + "metadata": {}, + "outputs": [], + "source": [ + "# Delete dataFrames\n", + "del data\n", + "del node_feature_df\n", + "del label_df\n", + "\n", + "if spread_features:\n", + " del merchant_specific_feature_df\n", + " del user_specific_feature_df\n", + "else:\n", + " del empty_feature_df" + ] + }, + { + "cell_type": "markdown", + "id": "657362a9", + "metadata": {}, + "source": [ + "#### Number of transaction nodes in training data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "47b9ccd9", + "metadata": {}, + "outputs": [], + "source": [ + "# Number of transaction nodes, needed for GNN training\n", + "nr_transaction_nodes = max_tx_id + 1\n", + "nr_transaction_nodes" + ] + }, + { + "cell_type": "markdown", + "id": "1fce29ee", + "metadata": {}, + "source": [ + "#### Save variable for training and inference" + ] + }, + { + "cell_type": "code", + "execution_count": 92, + "id": "c3bf9b46", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "variables_to_save = {\n", + " k: v for k, v in globals().items() if isinstance(v, (str, int)) and k.startswith('COL_')}" + ] + }, + { + "cell_type": "code", + "execution_count": 93, + "id": "54cc3c06", + "metadata": {}, + "outputs": [], + "source": [ + "variables_to_save['NUM_TRANSACTION_NODES'] = int(nr_transaction_nodes)" + ] + }, + { + "cell_type": "code", + "execution_count": 94, + "id": "9eb8bdd1", + "metadata": {}, + "outputs": [], + "source": [ + "# Save the dictionary to a JSON file\n", + "with open(os.path.join(sparkov_base_path, 'variables.json'), 'w') as json_file:\n", + " json.dump(variables_to_save, json_file, indent=4)" + ] + }, + { + "cell_type": "markdown", + "id": "fa2f6f28", + "metadata": {}, + "source": [ + "## That's it!\n", + "The data is now ready for processing" + ] + }, + { + "cell_type": "markdown", + "id": "49c13b3b", + "metadata": {}, + "source": [ + "## Copyright and License\n", + "
\n", + "Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved.\n", + "\n", + "
\n", + "\n", + " Licensed under the Apache License, Version 2.0 (the \"License\");\n", + " you may not use this file except in compliance with the License.\n", + " You may obtain a copy of the License at\n", + " \n", + " http://www.apache.org/licenses/LICENSE-2.0\n", + " \n", + " Unless required by applicable law or agreed to in writing, software\n", + " distributed under the License is distributed on an \"AS IS\" BASIS,\n", + " WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + " See the License for the specific language governing permissions and\n", + " limitations under the License." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "mamba_env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ai-credit-fraud-workflow/notebooks/preprocess_Tabformer.ipynb b/ai-credit-fraud-workflow/notebooks/preprocess_Tabformer.ipynb new file mode 100644 index 0000000..8c4aa3a --- /dev/null +++ b/ai-credit-fraud-workflow/notebooks/preprocess_Tabformer.ipynb @@ -0,0 +1,1944 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "9c6a5b09-a601-47c6-989f-5efb42d7f4f8", + "metadata": {}, + "source": [ + "# Credit Card Transaction Data Cleanup and Prep \n", + "\n", + "This notebook shows the steps for cleanup and preparing the credit card transaction data for follow on GNN training with GraphSAGE.\n", + "\n", + "### The dataset:\n", + " * IBM TabFormer: https://github.com/IBM/TabFormer\n", + " * Released under an Apache 2.0 license\n", + "\n", + "Contains 24M records with 15 fields, one field being the \"is fraud\" label which we use for training.\n", + "\n", + "### Goals\n", + "The goal is to:\n", + " * Cleanup the data\n", + " * Make field names just single word\n", + " * while field names are not used within the GNN, it makes accessing fields easier during cleanup \n", + " * Encode categorical fields\n", + " * use one-hot encoding for fields with less than 8 categories\n", + " * use binary encoding for fields with more than 8 categories\n", + " * Create a continuous node index across users, merchants, and transactions\n", + " * having node ID start at zero and then be contiguous is critical for creation of Compressed Sparse Row (CSR) formatted data without wasting memory.\n", + " * Produce:\n", + " * For XGBoost:\n", + " * Training - all data before 2018\n", + " * Validation - all data during 2018\n", + " * Test. - all data after 2018\n", + " * For GNN\n", + " * Training Data \n", + " * Edge List \n", + " * Feature data\n", + " * Test set - all data after 2018\n", + "\n", + "\n", + "\n", + "### Graph formation\n", + "Given that we are limited to just the data in the transaction file, the ideal model would be to have a bipartite graph of Users to Merchants where the edges represent the credit card transaction and then perform Link Classification on the Edges to identify fraud. Unfortunately the current version of cuGraph does not support GNN Link Prediction. That limitation will be lifted over the next few release at which time this code will be updated. Luckily, there is precedence for viewing transactions as nodes and then doing node classification using the popular GraphSAGE GNN. That is the approach this code takes. The produced graph will be a tri-partite graph where each transaction is represented as a node.\n", + "\n", + "\n", + "\n", + "\n", + "### Features\n", + "For the XGBoost approach, there is no need to generate empty features for the Merchants. However, for GNN processing, every node needs to have the same set of feature data. Therefore, we need to generate empty features for the User and Merchant nodes. \n", + "\n", + "-----" + ] + }, + { + "cell_type": "markdown", + "id": "795bdece", + "metadata": {}, + "source": [ + "#### Import the necessary libraries. In this case will be use cuDF and perform most of the data prep in GPU\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "4b6b2bc6-a206-42c5-aae9-590672b3a202", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "import json\n", + "import os\n", + "import pickle\n", + "\n", + "import cudf\n", + "import numpy as np\n", + "import pandas as pd\n", + "import scipy.stats as ss\n", + "from category_encoders import BinaryEncoder\n", + "from scipy.stats import pointbiserialr\n", + "from sklearn.compose import ColumnTransformer\n", + "from sklearn.impute import SimpleImputer\n", + "from sklearn.pipeline import Pipeline\n", + "from sklearn.preprocessing import OneHotEncoder, RobustScaler, StandardScaler" + ] + }, + { + "cell_type": "markdown", + "id": "81db641b", + "metadata": {}, + "source": [ + "-------\n", + "#### Define some arguments" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "016964ce", + "metadata": {}, + "outputs": [], + "source": [ + "# Whether the graph is undirected\n", + "make_undirected = True\n", + "\n", + "# Whether to spread features across Users and Merchants nodes\n", + "spread_features = False\n", + "\n", + "# Whether we should under-sample majority class (i.e. non-fraud transactions)\n", + "under_sample = True\n", + "\n", + "# Ration of fraud and non-fraud transactions in case we under-sample the majority class\n", + "fraud_ratio = 0.1\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "656e6aee-038a-4b58-9296-993e06defb35", + "metadata": {}, + "outputs": [], + "source": [ + "tabformer_base_path = '../data/TabFormer'\n", + "tabformer_raw_file_path = os.path.join(tabformer_base_path, 'raw', 'card_transaction.v1.csv')\n", + "tabformer_xgb = os.path.join(tabformer_base_path, 'xgb')\n", + "tabformer_gnn = os.path.join(tabformer_base_path, 'gnn')\n", + "\n", + "if not os.path.exists(tabformer_xgb):\n", + " os.makedirs(tabformer_xgb)\n", + "if not os.path.exists(tabformer_gnn):\n", + " os.makedirs(tabformer_gnn)" + ] + }, + { + "cell_type": "markdown", + "id": "96fe43fe", + "metadata": {}, + "source": [ + "--------\n", + "#### Load and understand the data" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "fb41e6ea-1e9f-4f14-99a4-d6d3df092a37", + "metadata": {}, + "outputs": [], + "source": [ + "# Read the dataset\n", + "data = cudf.read_csv(tabformer_raw_file_path)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3d9a9ab4-4240-4824-997b-8bfdb640381c", + "metadata": {}, + "outputs": [], + "source": [ + "# optional - take a look at the data \n", + "data.head(5)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d66495f5", + "metadata": {}, + "outputs": [], + "source": [ + "data.columns" + ] + }, + { + "cell_type": "markdown", + "id": "73172495", + "metadata": {}, + "source": [ + "#### Findings\n", + "* Ordinal categorical fields - 'Year', 'Month', 'Day'\n", + "* Nominal categorical fields - 'User', 'Card', 'Merchant Name', 'Merchant City', 'Merchant State', 'Zip', 'MCC', 'Errors?'\n", + "* Target label - 'Is Fraud?'" + ] + }, + { + "cell_type": "markdown", + "id": "f285adae", + "metadata": {}, + "source": [ + "#### Check if are there Null values in the data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c1f58262", + "metadata": {}, + "outputs": [], + "source": [ + "# Check which fields are missing values\n", + "data.isnull().sum()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c6f20eb9", + "metadata": {}, + "outputs": [], + "source": [ + "# Check percentage of missing values\n", + "100*data.isnull().sum()/len(data)" + ] + }, + { + "cell_type": "markdown", + "id": "805d62ba", + "metadata": {}, + "source": [ + "#### Findings\n", + "* For many transactions 'Merchant State' and 'Zip' are missing, but it's good that all of the transactions have 'Merchant City' specified. \n", + "* Over 98% of the transactions are missing data for 'Errors?' fields." + ] + }, + { + "cell_type": "markdown", + "id": "33487e74", + "metadata": {}, + "source": [ + "##### Save a few transactions before any operations on data" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "e8c188c1", + "metadata": {}, + "outputs": [], + "source": [ + "# Write a few raw transactions for model's inference notebook\n", + "out_path = os.path.join(tabformer_xgb, 'example_transactions.csv')\n", + "data.tail(10).to_pandas().to_csv(out_path, header=True, index=False)" + ] + }, + { + "cell_type": "markdown", + "id": "57513227", + "metadata": {}, + "source": [ + "#### Let's rename the column names to single words and use variables for column names to make access easier" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "d35f7230", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "COL_USER = 'User'\n", + "COL_CARD = 'Card'\n", + "COL_AMOUNT = 'Amount'\n", + "COL_MCC = 'MCC'\n", + "COL_TIME = 'Time'\n", + "COL_DAY = 'Day'\n", + "COL_MONTH = 'Month'\n", + "COL_YEAR = 'Year'\n", + "\n", + "COL_MERCHANT = 'Merchant'\n", + "COL_STATE ='State'\n", + "COL_CITY ='City'\n", + "COL_ZIP = 'Zip'\n", + "COL_ERROR = 'Errors'\n", + "COL_CHIP = 'Chip'\n", + "COL_FRAUD = 'Fraud'" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "90aa3fb5", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "_ = data.rename(columns={\n", + " \"Merchant Name\": COL_MERCHANT,\n", + " \"Merchant State\": COL_STATE,\n", + " \"Merchant City\": COL_CITY,\n", + " \"Errors?\": COL_ERROR,\n", + " \"Use Chip\": COL_CHIP,\n", + " \"Is Fraud?\": COL_FRAUD\n", + " },\n", + " inplace=True\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "ee33e39b", + "metadata": {}, + "source": [ + "#### Handle missing values\n", + "* Zip codes are numeral, replace missing zip codes by 0\n", + "* State and Error are string, replace missing values by marker 'XX'" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "79e24ab7", + "metadata": {}, + "outputs": [], + "source": [ + "UNKNOWN_STRING_MARKER = 'XX'\n", + "UNKNOWN_ZIP_CODE = 0" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "7b774e17", + "metadata": {}, + "outputs": [], + "source": [ + "# Make sure that 'XX' doesn't exist in State and Error field before we replace missing values by 'XX'\n", + "assert(UNKNOWN_STRING_MARKER not in set(data[COL_STATE].unique().to_pandas()))\n", + "assert(UNKNOWN_STRING_MARKER not in set(data[COL_ERROR].unique().to_pandas()))" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "a7964564", + "metadata": {}, + "outputs": [], + "source": [ + "# Make sure that 0 or 0.0 doesn't exist in Zip field before we replace missing values by 0\n", + "assert(float(0) not in set(data[COL_ZIP].unique().to_pandas()))\n", + "assert(0 not in set(data[COL_ZIP].unique().to_pandas()))" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "a1baca88", + "metadata": {}, + "outputs": [], + "source": [ + "# Replace missing values with markers\n", + "data[COL_STATE] = data[COL_STATE].fillna(UNKNOWN_STRING_MARKER)\n", + "data[COL_ERROR] = data[COL_ERROR].fillna(UNKNOWN_STRING_MARKER)\n", + "data[COL_ZIP] = data[COL_ZIP].fillna(UNKNOWN_ZIP_CODE)" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "07cf40c4", + "metadata": {}, + "outputs": [], + "source": [ + "# There shouldn't be any missing values in the data now.\n", + "assert(data.isnull().sum().sum() == 0)" + ] + }, + { + "cell_type": "markdown", + "id": "5f027291-5d0b-4917-ada0-a0dbe6b80f9b", + "metadata": {}, + "source": [ + "### Clean up the Amount field\n", + "* Drop the \"$\" from the Amount field and then convert from string to float\n", + "* Look into spread of Amount and choose right scaler for it" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "3ffe11c2-5e6d-4fac-8b42-27efb02afa61", + "metadata": {}, + "outputs": [], + "source": [ + "# Drop the \"$\" from the Amount field and then convert from string to float \n", + "data[COL_AMOUNT] = data[COL_AMOUNT].str.replace(\"$\",\"\").astype(\"float\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "09bd4966", + "metadata": {}, + "outputs": [], + "source": [ + "data[COL_AMOUNT].describe()" + ] + }, + { + "cell_type": "markdown", + "id": "ab82a9f9", + "metadata": {}, + "source": [ + "#### Let's look into how the Amount differ between fraud and non-fraud transactions" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "31679a1d", + "metadata": {}, + "outputs": [], + "source": [ + "# Fraud transactions\n", + "data[COL_AMOUNT][data[COL_FRAUD]=='Yes'].describe()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0f49ff49", + "metadata": {}, + "outputs": [], + "source": [ + "# Non-fraud transactions\n", + "data[COL_AMOUNT][data[COL_FRAUD]=='No'].describe()" + ] + }, + { + "cell_type": "markdown", + "id": "a190bb9e", + "metadata": {}, + "source": [ + "#### Findings\n", + "* 25th percentile = 9.2\n", + "* 75th percentile = 65\n", + "* Median is around 30 and the mean is around 43 whereas the max value is over 1200 and min value is -500\n", + "* Average amount in Fraud transactions > 2x the average amount in Non-Fraud transactions\n", + "\n", + "We need to scale the data, and RobustScaler could be a good choice for it." + ] + }, + { + "cell_type": "markdown", + "id": "b96a9ae1-1dcf-4480-a808-3afa913cb292", + "metadata": {}, + "source": [ + "#### Now the \"Fraud\" field" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d7b6c719", + "metadata": {}, + "outputs": [], + "source": [ + "# How many different categories are there in the COL_FRAUD column?\n", + "# The hope is that there are only two categories, 'Yes' and 'No'\n", + "data[COL_FRAUD].unique()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b5004040", + "metadata": {}, + "outputs": [], + "source": [ + "data[COL_FRAUD].value_counts()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "62d498e1", + "metadata": {}, + "outputs": [], + "source": [ + "100 * data[COL_FRAUD].value_counts()/len(data)" + ] + }, + { + "cell_type": "markdown", + "id": "a4f13282", + "metadata": {}, + "source": [ + "#### Change the 'Fraud' values to be integer where\n", + " * 1 == Fraud\n", + " * 0 == Non-fraud" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "aa31c844", + "metadata": {}, + "outputs": [], + "source": [ + "fraud_to_binary = {'No': 0, 'Yes': 1}\n", + "data[COL_FRAUD] = data[COL_FRAUD].map(fraud_to_binary).astype('int8')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7527510d", + "metadata": {}, + "outputs": [], + "source": [ + "data[COL_FRAUD].value_counts()" + ] + }, + { + "cell_type": "markdown", + "id": "54f56d5a-f135-4af2-ba13-b926f66a045f", + "metadata": {}, + "source": [ + "#### The 'City', 'State', and 'Zip' columns" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2dff40cb", + "metadata": {}, + "outputs": [], + "source": [ + "# City\n", + "data[COL_CITY].unique()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9cdf46ef", + "metadata": {}, + "outputs": [], + "source": [ + "# State\n", + "data[COL_STATE].unique()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "36297321-fb9b-48c6-afce-f083834eea4e", + "metadata": {}, + "outputs": [], + "source": [ + "# Zip\n", + "data[COL_ZIP].unique()" + ] + }, + { + "cell_type": "markdown", + "id": "ab51419d-c051-489b-af63-935248c133d0", + "metadata": {}, + "source": [ + "#### The 'Chip' column\n", + "\n", + "\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5ae85372-0f22-4850-bfa4-b8513b742663", + "metadata": {}, + "outputs": [], + "source": [ + "data[COL_CHIP].unique()" + ] + }, + { + "cell_type": "markdown", + "id": "22939e0f-bae0-4af3-aa3b-1b79974c0697", + "metadata": {}, + "source": [ + "#### The 'Error' column" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b877a558-4306-49f1-aa75-ba535be4470b", + "metadata": {}, + "outputs": [], + "source": [ + "data[COL_ERROR].unique()" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "id": "aa6a67c0", + "metadata": {}, + "outputs": [], + "source": [ + "# Remove ',' in error descriptions\n", + "data[COL_ERROR] = data[COL_ERROR].str.replace(\",\",\"\")" + ] + }, + { + "cell_type": "markdown", + "id": "86ca593a", + "metadata": {}, + "source": [ + "#### Findings\n", + "We can one hot or binary encode columns with fewer categories and binary/hash encode columns with more than 8 categories" + ] + }, + { + "cell_type": "markdown", + "id": "50933790-780c-43cc-833d-c7ad16acbde3", + "metadata": {}, + "source": [ + "#### Time\n", + "Time is captured as hour:minute.\n", + "\n", + "We are converting the time to just be the number of minutes.\n", + "\n", + "time = (hour * 60) + minutes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a97c5a95", + "metadata": {}, + "outputs": [], + "source": [ + "data[COL_TIME].describe()" + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "id": "1df15290-f60f-416d-81a4-437ff45b6d92", + "metadata": {}, + "outputs": [], + "source": [ + "# Split the time column into hours and minutes and then cast to int32\n", + "T = data[COL_TIME].str.split(':', expand=True)\n", + "T[0] = T[0].astype('int32')\n", + "T[1] = T[1].astype('int32')" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "id": "15d77736-53dd-4af6-a475-9ad812f84731", + "metadata": {}, + "outputs": [], + "source": [ + "# replace the 'Time' column with the new columns\n", + "data[COL_TIME] = (T[0] * 60 ) + T[1]\n", + "data[COL_TIME] = data[COL_TIME].astype(\"int32\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "id": "d51b6840-2912-4ecb-9998-7adc680f9d87", + "metadata": {}, + "outputs": [], + "source": [ + "# Delete temporary DataFrame\n", + "del(T)" + ] + }, + { + "cell_type": "markdown", + "id": "a8d41134", + "metadata": {}, + "source": [ + "#### Merchant column" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "aac83d7d", + "metadata": {}, + "outputs": [], + "source": [ + "data[COL_MERCHANT] " + ] + }, + { + "cell_type": "markdown", + "id": "2f79e111", + "metadata": {}, + "source": [ + "#### Convert the column to str type" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bd0348c4", + "metadata": {}, + "outputs": [], + "source": [ + "data[COL_MERCHANT] = data[COL_MERCHANT].astype('str')\n", + "\n", + "# TOver 100,000 merchants\n", + "data[COL_MERCHANT].unique()" + ] + }, + { + "cell_type": "markdown", + "id": "d8b4daee", + "metadata": {}, + "source": [ + "#### The Card column\n", + "* \"Card 0\" for User 1 is different from \"Card 0\" for User 2.\n", + "* Combine User and Card in a way such that (User, Card) combination is unique" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1a2abade", + "metadata": {}, + "outputs": [], + "source": [ + "data[COL_CARD].unique()" + ] + }, + { + "cell_type": "code", + "execution_count": 39, + "id": "068a05b0", + "metadata": {}, + "outputs": [], + "source": [ + "max_nr_cards_per_user = len(data[COL_CARD].unique())" + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "id": "5a64bb4f", + "metadata": {}, + "outputs": [], + "source": [ + "# Combine User and Card to generate unique numbers\n", + "data[COL_CARD] = data[COL_USER] * len(data[COL_CARD].unique()) + data[COL_CARD]\n", + "data[COL_CARD] = data[COL_CARD].astype('int')" + ] + }, + { + "cell_type": "markdown", + "id": "5a815bc9", + "metadata": {}, + "source": [ + "#### Define function to compute correlation of different categorical fields with target" + ] + }, + { + "cell_type": "code", + "execution_count": 41, + "id": "cfaa31fd", + "metadata": {}, + "outputs": [], + "source": [ + "# https://en.wikipedia.org/wiki/Cram%C3%A9r's_V\n", + "\n", + "def cramers_v(x, y):\n", + " confusion_matrix = cudf.crosstab(x, y).to_numpy()\n", + " chi2 = ss.chi2_contingency(confusion_matrix)[0]\n", + " n = confusion_matrix.sum().sum()\n", + " r, k = confusion_matrix.shape\n", + " return np.sqrt(chi2 / (n * (min(k-1, r-1))))" + ] + }, + { + "cell_type": "markdown", + "id": "1fa39773", + "metadata": {}, + "source": [ + "##### Compute correlation of different fields with target" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e5497f70", + "metadata": {}, + "outputs": [], + "source": [ + "sparse_factor = 1\n", + "columns_to_compute_corr = [COL_CARD, COL_CHIP, COL_ERROR, COL_STATE, COL_CITY, COL_ZIP, COL_MCC, COL_MERCHANT, COL_USER, COL_DAY, COL_MONTH, COL_YEAR]\n", + "for c1 in columns_to_compute_corr:\n", + " for c2 in [COL_FRAUD]:\n", + " coff = 100 * cramers_v(data[c1][::sparse_factor], data[c2][::sparse_factor])\n", + " print('Correlation ({}, {}) = {:6.2f}%'.format(c1, c2, coff))" + ] + }, + { + "cell_type": "markdown", + "id": "6dbc4636", + "metadata": {}, + "source": [ + "### Correlation of target with numerical columns" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7a624f77", + "metadata": {}, + "outputs": [], + "source": [ + "# https://en.wikipedia.org/wiki/Point-biserial_correlation_coefficient\n", + "# Use Point-biserial correlation coefficient(rpb) to check if the numerical columns are important to predict if a transaction is fraud\n", + "\n", + "\n", + "for col in [COL_TIME, COL_AMOUNT]:\n", + " r_pb, p_value = pointbiserialr(data[COL_FRAUD].to_pandas(), data[col].to_pandas())\n", + " print('r_pb ({}) = {:3.2f} with p_value {:3.2f}'.format(col, r_pb, p_value))" + ] + }, + { + "cell_type": "markdown", + "id": "041e3c50", + "metadata": {}, + "source": [ + "### Findings\n", + "* Clearly, Time is not an important predictor\n", + "* Amount has 3% correlation with target" + ] + }, + { + "cell_type": "markdown", + "id": "f92d58ef", + "metadata": {}, + "source": [ + "#### Based on correlation, select a set of columns (aka fields) to predict whether a transaction is fraud" + ] + }, + { + "cell_type": "code", + "execution_count": 44, + "id": "a00a7813", + "metadata": {}, + "outputs": [], + "source": [ + "# As the cross correlation of Fraud with Day, Month, Year is significantly lower,\n", + "# we can skip them for now and add these features later.\n", + "\n", + "numerical_predictors = [COL_AMOUNT]\n", + "nominal_predictors = [COL_ERROR, COL_CARD, COL_CHIP, COL_CITY, COL_ZIP, COL_MCC, COL_MERCHANT]\n", + "\n", + "predictor_columns = numerical_predictors + nominal_predictors\n", + "\n", + "target_column = [COL_FRAUD]" + ] + }, + { + "cell_type": "markdown", + "id": "9341f5e0", + "metadata": {}, + "source": [ + "#### Remove duplicates non-fraud data points" + ] + }, + { + "cell_type": "code", + "execution_count": 45, + "id": "428c4ac8", + "metadata": {}, + "outputs": [], + "source": [ + "# Remove duplicates data points\n", + "fraud_data = data[data[COL_FRAUD] == 1]\n", + "data = data[data[COL_FRAUD] == 0]\n", + "data = data.drop_duplicates(subset=nominal_predictors)\n", + "data = cudf.concat([data, fraud_data])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14a8bbce", + "metadata": {}, + "outputs": [], + "source": [ + "# Percentage of fraud and non-fraud cases\n", + "100*data[COL_FRAUD].value_counts()/len(data)" + ] + }, + { + "cell_type": "markdown", + "id": "8bc2ebac", + "metadata": {}, + "source": [ + "### Split the data into\n", + "The data will be split into thee groups based on event date\n", + " * Training - all data before 2018\n", + " * Validation - all data during 2018\n", + " * Test. - all data after 2018" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "97dc748b", + "metadata": {}, + "outputs": [], + "source": [ + "if under_sample: \n", + " fraud_df = data[data[COL_FRAUD]==1]\n", + " non_fraud_df = data[data[COL_FRAUD]==0]\n", + " nr_non_fraud_samples = min((len(data) - len(fraud_df)), int(len(fraud_df)/fraud_ratio))\n", + " data = cudf.concat([fraud_df, non_fraud_df.sample(nr_non_fraud_samples)])\n", + "\n", + "training_idx = data[COL_YEAR] < 2018\n", + "validation_idx = data[COL_YEAR] == 2018\n", + "test_idx = data[COL_YEAR] > 2018\n", + "\n", + "data[COL_FRAUD].value_counts()" + ] + }, + { + "cell_type": "markdown", + "id": "cd036929", + "metadata": {}, + "source": [ + "### Scale numerical columns and encode categorical columns of training data" + ] + }, + { + "cell_type": "code", + "execution_count": 48, + "id": "2d570cdc", + "metadata": {}, + "outputs": [], + "source": [ + "# As some of the encoder we want to use is not available in cuml, we can use pandas for now.\n", + "# Move training data to pandas for preprocessing\n", + "pdf_training = data[training_idx].to_pandas()[predictor_columns + target_column]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d135cc8a", + "metadata": {}, + "outputs": [], + "source": [ + "#Use one-hot encoding for columns with <= 8 categories, and binary encoding for columns with more categories \n", + "columns_for_binary_encoding = []\n", + "columns_for_onehot_encoding = []\n", + "for col in nominal_predictors:\n", + " print(col, len(data[col].unique()))\n", + " if len(data[col].unique()) <= 8:\n", + " columns_for_onehot_encoding.append(col)\n", + " else:\n", + " columns_for_binary_encoding.append(col)" + ] + }, + { + "cell_type": "code", + "execution_count": 50, + "id": "ee6b77fc", + "metadata": {}, + "outputs": [], + "source": [ + "# Mark categorical column as \"category\"\n", + "pdf_training[nominal_predictors] = pdf_training[nominal_predictors].astype(\"category\")" + ] + }, + { + "cell_type": "code", + "execution_count": 51, + "id": "49eb57f5", + "metadata": {}, + "outputs": [], + "source": [ + "# encoders to encode categorical columns and scalers to scale numerical columns\n", + "\n", + "bin_encoder = Pipeline(\n", + " steps=[\n", + " (\"binary\", BinaryEncoder(handle_missing='value', handle_unknown='value'))\n", + " ]\n", + ")\n", + "onehot_encoder = Pipeline(\n", + " steps=[\n", + " (\"onehot\", OneHotEncoder())\n", + " ]\n", + ")\n", + "std_scaler = Pipeline(\n", + " steps=[(\"imputer\", SimpleImputer(strategy=\"median\")), (\"standard\", StandardScaler())],\n", + ")\n", + "robust_scaler = Pipeline(\n", + " steps=[(\"imputer\", SimpleImputer(strategy=\"median\")), (\"robust\", RobustScaler())],\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 52, + "id": "613db861", + "metadata": {}, + "outputs": [], + "source": [ + "# compose encoders and scalers in a column transformer\n", + "transformer = ColumnTransformer(\n", + " transformers=[\n", + " (\"binary\", bin_encoder, columns_for_binary_encoding),\n", + " (\"onehot\", onehot_encoder, columns_for_onehot_encoding),\n", + " (\"robust\", robust_scaler, [COL_AMOUNT]),\n", + " ], remainder=\"passthrough\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 53, + "id": "de594998", + "metadata": {}, + "outputs": [], + "source": [ + "# Fit column transformer with training data\n", + "\n", + "pd.set_option('future.no_silent_downcasting', True)\n", + "transformer = transformer.fit(pdf_training[predictor_columns])" + ] + }, + { + "cell_type": "code", + "execution_count": 54, + "id": "e3f88ece", + "metadata": {}, + "outputs": [], + "source": [ + "# transformed column names\n", + "columns_of_transformed_data = list(\n", + " map(lambda name: name.split('__')[1],\n", + " list(transformer.get_feature_names_out(predictor_columns))))" + ] + }, + { + "cell_type": "code", + "execution_count": 55, + "id": "2bdc0acc", + "metadata": {}, + "outputs": [], + "source": [ + "# data type of transformed columns \n", + "type_mapping = {}\n", + "for col in columns_of_transformed_data:\n", + " if col.split('_')[0] in nominal_predictors:\n", + " type_mapping[col] = 'int8'\n", + " elif col in numerical_predictors:\n", + " type_mapping[col] = 'float'\n", + " elif col in target_column:\n", + " type_mapping[col] = data.dtypes.to_dict()[col]" + ] + }, + { + "cell_type": "code", + "execution_count": 56, + "id": "76332e33", + "metadata": {}, + "outputs": [], + "source": [ + "# transform training data\n", + "preprocessed_training_data = transformer.transform(pdf_training[predictor_columns])\n", + "\n", + "# Convert transformed data to panda DataFrame\n", + "preprocessed_training_data = pd.DataFrame(\n", + " preprocessed_training_data, columns=columns_of_transformed_data)\n", + "# Copy target column\n", + "preprocessed_training_data[COL_FRAUD] = pdf_training[COL_FRAUD].values\n", + "preprocessed_training_data = preprocessed_training_data.astype(type_mapping)" + ] + }, + { + "cell_type": "code", + "execution_count": 57, + "id": "078b4f3f", + "metadata": {}, + "outputs": [], + "source": [ + "# Save the transformer \n", + "\n", + "with open(os.path.join(tabformer_base_path, 'preprocessor.pkl'),'wb') as f:\n", + " pickle.dump(transformer, f)" + ] + }, + { + "cell_type": "markdown", + "id": "48e46229", + "metadata": {}, + "source": [ + "#### Save transformed training data for XGBoost training" + ] + }, + { + "cell_type": "code", + "execution_count": 58, + "id": "e3362673", + "metadata": {}, + "outputs": [], + "source": [ + "with open(os.path.join(tabformer_base_path, 'preprocessor.pkl'),'rb') as f:\n", + " loaded_transformer = pickle.load(f)" + ] + }, + { + "cell_type": "code", + "execution_count": 59, + "id": "47f6663e", + "metadata": {}, + "outputs": [], + "source": [ + "# Transform test data using the transformer fitted on training data\n", + "pdf_test = data[test_idx].to_pandas()[predictor_columns + target_column]\n", + "pdf_test[nominal_predictors] = pdf_test[nominal_predictors].astype(\"category\")\n", + "\n", + "preprocessed_test_data = loaded_transformer.transform(pdf_test[predictor_columns])\n", + "preprocessed_test_data = pd.DataFrame(preprocessed_test_data, columns=columns_of_transformed_data)\n", + "\n", + "# Copy target column\n", + "preprocessed_test_data[COL_FRAUD] = pdf_test[COL_FRAUD].values\n", + "preprocessed_test_data = preprocessed_test_data.astype(type_mapping)" + ] + }, + { + "cell_type": "code", + "execution_count": 60, + "id": "0ce80a1b", + "metadata": {}, + "outputs": [], + "source": [ + "# Transform validation data using the transformer fitted on training data\n", + "pdf_validation = data[validation_idx].to_pandas()[predictor_columns + target_column]\n", + "pdf_validation[nominal_predictors] = pdf_validation[nominal_predictors].astype(\"category\")\n", + "\n", + "preprocessed_validation_data = loaded_transformer.transform(pdf_validation[predictor_columns])\n", + "preprocessed_validation_data = pd.DataFrame(preprocessed_validation_data, columns=columns_of_transformed_data)\n", + "\n", + "# Copy target column\n", + "preprocessed_validation_data[COL_FRAUD] = pdf_validation[COL_FRAUD].values\n", + "preprocessed_validation_data = preprocessed_validation_data.astype(type_mapping)" + ] + }, + { + "cell_type": "markdown", + "id": "cb2ca66b-d3dc-4f67-9754-b90bbea6e286", + "metadata": {}, + "source": [ + "## Write out the data for XGB" + ] + }, + { + "cell_type": "code", + "execution_count": 61, + "id": "89c16cfb-0bd4-4efb-a610-f3ae7445d96e", + "metadata": {}, + "outputs": [], + "source": [ + "## Training data\n", + "out_path = os.path.join(tabformer_xgb, 'training.csv')\n", + "if not os.path.exists(os.path.dirname(out_path)):\n", + " os.makedirs(os.path.dirname(out_path))\n", + "preprocessed_training_data.to_csv(out_path, header=True, index=False, columns=columns_of_transformed_data + target_column)\n", + "# preprocessed_training_data.to_parquet(out_path, index=False, compression='gzip')" + ] + }, + { + "cell_type": "code", + "execution_count": 62, + "id": "f3ef6b19-062b-42d5-8caa-6f6011648b4a", + "metadata": {}, + "outputs": [], + "source": [ + "## validation data\n", + "out_path = os.path.join(tabformer_xgb, 'validation.csv')\n", + "if not os.path.exists(os.path.dirname(out_path)):\n", + " os.makedirs(os.path.dirname(out_path))\n", + "preprocessed_validation_data.to_csv(out_path, header=True, index=False, columns=columns_of_transformed_data + target_column)\n", + "# preprocessed_validation_data.to_parquet(out_path, index=False, compression='gzip')" + ] + }, + { + "cell_type": "code", + "execution_count": 63, + "id": "cdc8e3b9-841d-49f3-b3ae-3017a04605e3", + "metadata": {}, + "outputs": [], + "source": [ + "## test data\n", + "out_path = os.path.join(tabformer_xgb, 'test.csv')\n", + "preprocessed_test_data.to_csv(out_path, header=True, index=False, columns=columns_of_transformed_data + target_column)\n", + "# preprocessed_test_data.to_parquet(out_path, index=False, compression='gzip')" + ] + }, + { + "cell_type": "code", + "execution_count": 64, + "id": "67fb32b0", + "metadata": {}, + "outputs": [], + "source": [ + "# Write untransformed test data that has only (renamed) predictor columns and target\n", + "out_path = os.path.join(tabformer_xgb, 'untransformed_test.csv')\n", + "pdf_test.to_csv(out_path, header=True, index=False)" + ] + }, + { + "cell_type": "code", + "execution_count": 65, + "id": "2d6cb604", + "metadata": {}, + "outputs": [], + "source": [ + "# Delete dataFrames that are not needed anymore\n", + "del(pdf_training)\n", + "del(pdf_validation)\n", + "del(pdf_test)\n", + "del(preprocessed_training_data)\n", + "del(preprocessed_validation_data)\n", + "del(preprocessed_test_data)" + ] + }, + { + "cell_type": "markdown", + "id": "3bfbfd83", + "metadata": {}, + "source": [ + "### GNN Data" + ] + }, + { + "cell_type": "markdown", + "id": "98e518c8", + "metadata": {}, + "source": [ + "#### Setting Vertex IDs\n", + "In order to create a graph, the different vertices need to be assigned unique vertex IDs. Additionally, the IDs needs to be consecutive and positive.\n", + "\n", + "There are three nodes groups here: Transactions, Users, and Merchants. \n", + "\n", + "This IDs are not used in training, just used for graph processing." + ] + }, + { + "cell_type": "code", + "execution_count": 66, + "id": "194a47d8", + "metadata": {}, + "outputs": [], + "source": [ + "# Use the same training data as used for XGBoost\n", + "data = data[training_idx]" + ] + }, + { + "cell_type": "code", + "execution_count": 67, + "id": "0ba0cb6b", + "metadata": {}, + "outputs": [], + "source": [ + "# a lot of process has occurred, sort the data and reset the index\n", + "data = data.sort_values(by=[COL_YEAR, COL_MONTH, COL_DAY, COL_TIME], ascending=False)\n", + "data.reset_index(inplace=True, drop=True)" + ] + }, + { + "cell_type": "code", + "execution_count": 68, + "id": "2a75c92e", + "metadata": {}, + "outputs": [], + "source": [ + "# Each transaction gets a unique ID\n", + "COL_TRANSACTION_ID = 'Tx_ID'\n", + "COL_MERCHANT_ID = 'Merchant_ID'\n", + "COL_USER_ID = 'User_ID'\n", + "\n", + "# The number of transaction is the same as the size of the list, and hence the index value\n", + "data[COL_TRANSACTION_ID] = data.index" + ] + }, + { + "cell_type": "code", + "execution_count": 69, + "id": "472ea57c", + "metadata": {}, + "outputs": [], + "source": [ + "# Get the max transaction ID to compute first merchant ID\n", + "max_tx_id = data[COL_TRANSACTION_ID].max()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7e8ef04b", + "metadata": {}, + "outputs": [], + "source": [ + "# Convert Merchant string to consecutive integers\n", + "merchant_name_to_id = dict((v, k) for k, v in data[COL_MERCHANT].unique().to_dict().items())\n", + "data[COL_MERCHANT_ID] = data[COL_MERCHANT].map(merchant_name_to_id) + (max_tx_id + 1)\n", + "data[COL_MERCHANT_ID].min(), data[COL_MERCHANT].max()" + ] + }, + { + "cell_type": "code", + "execution_count": 71, + "id": "6937df18", + "metadata": {}, + "outputs": [], + "source": [ + "# Again, get the max merchant ID to compute first user ID\n", + "max_merchant_id = data[COL_MERCHANT_ID].max()" + ] + }, + { + "cell_type": "markdown", + "id": "b153352c", + "metadata": {}, + "source": [ + "##### NOTE: the 'User' and 'Card' columns of the original data were used to crate updated 'Card' colum\n", + "* You can use user or card as nodes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "030a2335", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "# Convert Card to consecutive IDs\n", + "id_to_consecutive_id = dict((v, k) for k, v in data[COL_CARD].unique().to_dict().items())\n", + "data[COL_USER_ID] = data[COL_CARD].map(id_to_consecutive_id) + max_merchant_id + 1\n", + "data[COL_USER_ID].min(), data[COL_USER_ID].max()\n", + "\n", + "# id_to_consecutive_id = dict((v, k) for k, v in data[COL_USER].unique().to_dict().items())\n", + "# data[COL_USER_ID] = data[COL_USER].map(id_to_consecutive_id) + max_merchant_id + 1\n", + "# data[COL_USER_ID].min(), data[COL_USER].max()" + ] + }, + { + "cell_type": "code", + "execution_count": 73, + "id": "28858422", + "metadata": {}, + "outputs": [], + "source": [ + "# Save the max user ID\n", + "max_user_id = data[COL_USER_ID].max()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "903e5115", + "metadata": {}, + "outputs": [], + "source": [ + "# Check the the transaction, merchant and user ids are consecutive\n", + "id_range = data[COL_TRANSACTION_ID].min(), data[COL_TRANSACTION_ID].max()\n", + "print(f'Transaction ID range {id_range}')\n", + "id_range = data[COL_MERCHANT_ID].min(), data[COL_MERCHANT_ID].max()\n", + "print(f'Merchant ID range {id_range}')\n", + "id_range = data[COL_USER_ID].min(), data[COL_USER_ID].max()\n", + "print(f'User ID range {id_range}')" + ] + }, + { + "cell_type": "code", + "execution_count": 75, + "id": "f2d0dfde", + "metadata": {}, + "outputs": [], + "source": [ + "# Sanity checks\n", + "assert( data[COL_TRANSACTION_ID].max() == data[COL_MERCHANT_ID].min() - 1)\n", + "assert( data[COL_MERCHANT_ID].max() == data[COL_USER_ID].min() - 1)\n", + "assert(len(data[COL_USER_ID].unique()) == (data[COL_USER_ID].max() - data[COL_USER_ID].min() + 1))\n", + "assert(len(data[COL_MERCHANT_ID].unique()) == (data[COL_MERCHANT_ID].max() - data[COL_MERCHANT_ID].min() + 1))\n", + "assert(len(data[COL_TRANSACTION_ID].unique()) == (data[COL_TRANSACTION_ID].max() - data[COL_TRANSACTION_ID].min() + 1))" + ] + }, + { + "cell_type": "markdown", + "id": "0d9c3df3-a5be-4899-8bf9-6152aca114c7", + "metadata": {}, + "source": [ + "### Write out the data for GNN" + ] + }, + { + "cell_type": "markdown", + "id": "c2b86862-d129-4ece-a60d-dc798f3a68b5", + "metadata": {}, + "source": [ + "#### Create the Graph Edge Data file \n", + "The file is in COO format" + ] + }, + { + "cell_type": "code", + "execution_count": 76, + "id": "b288c5a7-20dd-40ff-b0eb-7a5895bcc464", + "metadata": {}, + "outputs": [], + "source": [ + "COL_GRAPH_SRC = 'src'\n", + "COL_GRAPH_DST = 'dst'\n", + "COL_GRAPH_WEIGHT = 'wgt'\n", + "\n", + "# User to Transactions\n", + "U_2_T = cudf.DataFrame()\n", + "U_2_T[COL_GRAPH_SRC] = data[COL_USER_ID]\n", + "U_2_T[COL_GRAPH_DST] = data[COL_TRANSACTION_ID]\n", + "if make_undirected:\n", + " T_2_U = cudf.DataFrame()\n", + " T_2_U[COL_GRAPH_SRC] = data[COL_TRANSACTION_ID]\n", + " T_2_U[COL_GRAPH_DST] = data[COL_USER_ID]\n", + " U_2_T = cudf.concat([U_2_T, T_2_U])\n", + " del T_2_U\n" + ] + }, + { + "cell_type": "code", + "execution_count": 77, + "id": "a970747d-07a2-43b3-b39c-19a0196fa5b1", + "metadata": {}, + "outputs": [], + "source": [ + "# Transactions to Merchants\n", + "T_2_M = cudf.DataFrame()\n", + "T_2_M[COL_GRAPH_SRC] = data[COL_MERCHANT_ID]\n", + "T_2_M[COL_GRAPH_DST] = data[COL_TRANSACTION_ID]\n", + "\n", + "if make_undirected:\n", + " M_2_T = cudf.DataFrame()\n", + " M_2_T[COL_GRAPH_SRC] = data[COL_TRANSACTION_ID]\n", + " M_2_T[COL_GRAPH_DST] = data[COL_MERCHANT_ID]\n", + " T_2_M = cudf.concat([T_2_M, M_2_T])\n", + " del M_2_T" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "80e704fd-ae9f-45b1-ad56-bdc0b743d09f", + "metadata": {}, + "outputs": [], + "source": [ + "Edge = cudf.concat([U_2_T, T_2_M])\n", + "Edge[COL_GRAPH_WEIGHT] = 0.0\n", + "len(Edge)" + ] + }, + { + "cell_type": "code", + "execution_count": 79, + "id": "c74572f6-ff6e-4c8f-803e-0ae2c0587c58", + "metadata": {}, + "outputs": [], + "source": [ + "# now write out the data\n", + "out_path = os.path.join (tabformer_gnn, 'edges.csv')\n", + "\n", + "if not os.path.exists(os.path.dirname(out_path)):\n", + " os.makedirs(os.path.dirname(out_path))\n", + " \n", + "Edge.to_csv(out_path, header=False, index=False)" + ] + }, + { + "cell_type": "code", + "execution_count": 80, + "id": "3dd3ff45-3796-4069-9e3a-587743c4e1e0", + "metadata": {}, + "outputs": [], + "source": [ + "del(Edge)\n", + "del(U_2_T)\n", + "del(T_2_M)" + ] + }, + { + "cell_type": "markdown", + "id": "ed00c481-1737-4152-9d23-f3cb24f2adcd", + "metadata": {}, + "source": [ + "### Now the feature data\n", + "Feature data needs to be is sorted in order, where the row index corresponds to the node ID\n", + "\n", + "The data is comprised of three sets of features\n", + "* Transactions\n", + "* Users\n", + "* Merchants" + ] + }, + { + "cell_type": "markdown", + "id": "805c9d23", + "metadata": {}, + "source": [ + "#### To get feature vectors of Transaction nodes, transform the training data using pre-fitted transformer" + ] + }, + { + "cell_type": "code", + "execution_count": 81, + "id": "584fe9bf", + "metadata": {}, + "outputs": [], + "source": [ + "node_feature_df = pd.DataFrame(\n", + " loaded_transformer.transform(\n", + " data[predictor_columns].to_pandas()\n", + " ),\n", + " columns=columns_of_transformed_data).astype(type_mapping)\n", + "\n", + "node_feature_df[COL_FRAUD] = data[COL_FRAUD].to_pandas()" + ] + }, + { + "cell_type": "markdown", + "id": "55aa8f86", + "metadata": {}, + "source": [ + "#### For graph nodes associated with merchant and user, add feature vectors of zeros" + ] + }, + { + "cell_type": "code", + "execution_count": 82, + "id": "b35f9f5b", + "metadata": {}, + "outputs": [], + "source": [ + "# Number of graph nodes for users and merchants \n", + "nr_users_and_merchant_nodes = max_user_id - max_tx_id" + ] + }, + { + "cell_type": "code", + "execution_count": 83, + "id": "b5d312bd", + "metadata": {}, + "outputs": [], + "source": [ + "if not spread_features:\n", + " # Create feature vector of all zeros for each user and merchant node\n", + " empty_feature_df = cudf.DataFrame(\n", + " columns=columns_of_transformed_data + target_column,\n", + " dtype='int8', \n", + " index=range(nr_users_and_merchant_nodes)\n", + " )\n", + " empty_feature_df = empty_feature_df.fillna(0)\n", + " empty_feature_df=empty_feature_df.astype(type_mapping)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 84, + "id": "a72d3ea5-e04f-4af1-a0e0-09964555c1ed", + "metadata": {}, + "outputs": [], + "source": [ + "if not spread_features:\n", + " # Concatenate transaction features followed by features for merchants and user nodes\n", + " node_feature_df = pd.concat([node_feature_df, empty_feature_df.to_pandas()]).astype(type_mapping)" + ] + }, + { + "cell_type": "code", + "execution_count": 85, + "id": "a364d173", + "metadata": {}, + "outputs": [], + "source": [ + "# User specific columns\n", + "if spread_features:\n", + " user_specific_columns = [COL_CARD, COL_CHIP]\n", + " user_specific_columns_of_transformed_data = []\n", + "\n", + " for col in node_feature_df.columns:\n", + " if col.split('_')[0] in user_specific_columns:\n", + " user_specific_columns_of_transformed_data.append(col)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 86, + "id": "92d88c2f", + "metadata": {}, + "outputs": [], + "source": [ + "# Merchant specific columns\n", + "if spread_features:\n", + " merchant_specific_columns = [COL_MERCHANT, COL_CITY, COL_ZIP, COL_MCC]\n", + " merchant_specific_columns_of_transformed_data = []\n", + " \n", + " for col in node_feature_df.columns:\n", + " if col.split('_')[0] in merchant_specific_columns:\n", + " merchant_specific_columns_of_transformed_data.append(col)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 87, + "id": "f62755ae", + "metadata": {}, + "outputs": [], + "source": [ + "# Transaction specific columns\n", + "if spread_features:\n", + " transaction_specific_columns = list(\n", + " set(numerical_predictors).union(nominal_predictors)\n", + " - set(user_specific_columns).union(merchant_specific_columns))\n", + " transaction_specific_columns_of_transformed_data = []\n", + " \n", + " for col in node_feature_df.columns:\n", + " if col.split('_')[0] in transaction_specific_columns:\n", + " transaction_specific_columns_of_transformed_data.append(col) " + ] + }, + { + "cell_type": "markdown", + "id": "d12061da", + "metadata": {}, + "source": [ + "#### Construct feature vector for merchants" + ] + }, + { + "cell_type": "code", + "execution_count": 88, + "id": "de484a27", + "metadata": {}, + "outputs": [], + "source": [ + "if spread_features:\n", + " # Find indices of unique merchants\n", + " idx_df = cudf.DataFrame()\n", + " idx_df[COL_MERCHANT_ID] = data[COL_MERCHANT_ID]\n", + " idx_df = idx_df.sort_values(by=COL_MERCHANT_ID)\n", + " idx_df = idx_df.drop_duplicates(subset=COL_MERCHANT_ID)\n", + " assert((data.iloc[idx_df.index][COL_MERCHANT_ID] == idx_df[COL_MERCHANT_ID]).all())" + ] + }, + { + "cell_type": "code", + "execution_count": 89, + "id": "5be790eb", + "metadata": {}, + "outputs": [], + "source": [ + "if spread_features:\n", + " # Copy merchant specific columns, and set the rest to zero\n", + " merchant_specific_feature_df = node_feature_df.iloc[idx_df.index.to_numpy()]\n", + " merchant_specific_feature_df.\\\n", + " loc[:, \n", + " transaction_specific_columns_of_transformed_data +\n", + " user_specific_columns_of_transformed_data] = 0.0\n" + ] + }, + { + "cell_type": "code", + "execution_count": 90, + "id": "576091c6", + "metadata": {}, + "outputs": [], + "source": [ + "if spread_features:\n", + " # Find indices of unique users\n", + " idx_df = cudf.DataFrame()\n", + " idx_df[COL_USER_ID] = data[COL_USER_ID]\n", + " idx_df = idx_df.sort_values(by=COL_USER_ID)\n", + " idx_df = idx_df.drop_duplicates(subset=COL_USER_ID)\n", + " assert((data.iloc[idx_df.index][COL_USER_ID] == idx_df[COL_USER_ID]).all())" + ] + }, + { + "cell_type": "code", + "execution_count": 91, + "id": "aec23ee5", + "metadata": {}, + "outputs": [], + "source": [ + "if spread_features:\n", + " # Copy user specific columns, and set the rest to zero\n", + " user_specific_feature_df = node_feature_df.iloc[idx_df.index.to_numpy()]\n", + " user_specific_feature_df.\\\n", + " loc[:,\n", + " transaction_specific_columns_of_transformed_data +\n", + " merchant_specific_columns_of_transformed_data] = 0.0 " + ] + }, + { + "cell_type": "code", + "execution_count": 92, + "id": "8296a341", + "metadata": {}, + "outputs": [], + "source": [ + "# Concatenate features of node, user and merchant\n", + "if spread_features:\n", + " \n", + " node_feature_df[merchant_specific_columns_of_transformed_data] = 0.0\n", + " node_feature_df[user_specific_columns_of_transformed_data] = 0.0\n", + " node_feature_df = pd.concat(\n", + " [node_feature_df, merchant_specific_feature_df, user_specific_feature_df]\n", + " ).astype(type_mapping)\n", + " \n", + " # features to save\n", + " node_feature_df = node_feature_df[\n", + " transaction_specific_columns_of_transformed_data +\n", + " merchant_specific_columns_of_transformed_data +\n", + " user_specific_columns_of_transformed_data + [COL_FRAUD]]\n" + ] + }, + { + "cell_type": "code", + "execution_count": 93, + "id": "527f6ea8", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "# target labels to save\n", + "label_df = node_feature_df[[COL_FRAUD]]" + ] + }, + { + "cell_type": "code", + "execution_count": 94, + "id": "15e1cba8", + "metadata": {}, + "outputs": [], + "source": [ + "# Remove target label from feature vectors\n", + "_ = node_feature_df.drop(columns=[COL_FRAUD], inplace=True)" + ] + }, + { + "cell_type": "markdown", + "id": "310d9500", + "metadata": {}, + "source": [ + "#### Write out node features and target labels" + ] + }, + { + "cell_type": "code", + "execution_count": 95, + "id": "139bfd9f", + "metadata": {}, + "outputs": [], + "source": [ + "# Write node target label to csv file\n", + "out_path = os.path.join(tabformer_gnn, 'labels.csv')\n", + "\n", + "if not os.path.exists(os.path.dirname(out_path)):\n", + " os.makedirs(os.path.dirname(out_path))\n", + "\n", + "label_df.to_csv(out_path, header=False, index=False)\n", + "# label_df.to_parquet(out_path, index=False, compression='gzip')" + ] + }, + { + "cell_type": "code", + "execution_count": 96, + "id": "b8fe801e", + "metadata": {}, + "outputs": [], + "source": [ + "# Write node features to csv file\n", + "out_path = os.path.join(tabformer_gnn, 'features.csv')\n", + "\n", + "if not os.path.exists(os.path.dirname(out_path)):\n", + " os.makedirs(os.path.dirname(out_path))\n", + "node_feature_df[columns_of_transformed_data].to_csv(out_path, header=True, index=False)\n", + "# node_feature_df.to_parquet(out_path, index=False, compression='gzip')" + ] + }, + { + "cell_type": "code", + "execution_count": 97, + "id": "fbe75d91", + "metadata": {}, + "outputs": [], + "source": [ + "# Delete dataFrames\n", + "del data\n", + "del node_feature_df\n", + "del label_df\n", + "\n", + "if spread_features:\n", + " del merchant_specific_feature_df\n", + " del user_specific_feature_df\n", + "else:\n", + " del empty_feature_df" + ] + }, + { + "cell_type": "markdown", + "id": "2c6afd9b", + "metadata": {}, + "source": [ + "#### Number of transaction nodes in training data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2a5f5bd1", + "metadata": {}, + "outputs": [], + "source": [ + "# Number of transaction nodes, needed for GNN training\n", + "nr_transaction_nodes = max_tx_id + 1\n", + "nr_transaction_nodes" + ] + }, + { + "cell_type": "markdown", + "id": "275bfc8b", + "metadata": {}, + "source": [ + "#### Maximum number of cards per user" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "867661d9", + "metadata": {}, + "outputs": [], + "source": [ + "# Max number of cards per user, needed for inference\n", + "max_nr_cards_per_user" + ] + }, + { + "cell_type": "markdown", + "id": "cf5434a7", + "metadata": {}, + "source": [ + "#### Save variable for training and inference" + ] + }, + { + "cell_type": "code", + "execution_count": 100, + "id": "9d741c6c", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "variables_to_save = {\n", + " k: v for k, v in globals().items() if isinstance(v, (str, int)) and k.startswith('COL_')}" + ] + }, + { + "cell_type": "code", + "execution_count": 101, + "id": "86727cef", + "metadata": {}, + "outputs": [], + "source": [ + "variables_to_save['NUM_TRANSACTION_NODES'] = int(nr_transaction_nodes)\n", + "variables_to_save['MAX_NR_CARDS_PER_USER'] = int(max_nr_cards_per_user)" + ] + }, + { + "cell_type": "code", + "execution_count": 102, + "id": "6a59a5a7", + "metadata": {}, + "outputs": [], + "source": [ + "# Save the dictionary to a JSON file\n", + "\n", + "with open(os.path.join(tabformer_base_path, 'variables.json'), 'w') as json_file:\n", + " json.dump(variables_to_save, json_file, indent=4)" + ] + }, + { + "cell_type": "markdown", + "id": "fa2f6f28", + "metadata": {}, + "source": [ + "## That's it!\n", + "The data is now ready for processing\n", + "\n", + "## Copyright and License\n", + "
\n", + "Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved.\n", + "\n", + "
\n", + "\n", + " Licensed under the Apache License, Version 2.0 (the \"License\");\n", + " you may not use this file except in compliance with the License.\n", + " You may obtain a copy of the License at\n", + " \n", + " http://www.apache.org/licenses/LICENSE-2.0\n", + " \n", + " Unless required by applicable law or agreed to in writing, software\n", + " distributed under the License is distributed on an \"AS IS\" BASIS,\n", + " WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + " See the License for the specific language governing permissions and\n", + " limitations under the License." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ai-credit-fraud-workflow/notebooks/train_gnn_based_xgboost.ipynb b/ai-credit-fraud-workflow/notebooks/train_gnn_based_xgboost.ipynb new file mode 100644 index 0000000..268dec0 --- /dev/null +++ b/ai-credit-fraud-workflow/notebooks/train_gnn_based_xgboost.ipynb @@ -0,0 +1,1161 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Train a GNN-based XGBoost Model\n", + "#### Goals\n", + "* Train a GNN (GraphSAGE) model that produces node (transaction) embeddings.\n", + "* Use these node embeddings to train an XGBoost model.\n", + "* Save the trained GNN and XGBoost models for inference.\n", + "\n", + "__Prerequisite__: The preprocessing notebook must be executed before running this notebook." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Dataset names" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "# Name of the datasets to choose from\n", + "TABFORMER = \"TabFormer\"\n", + "SPARKOV = \"Sparkov\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Select the dataset to train the models on" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Note__: This notebook works for both __TabFormer__ and __Sparkov__ dataset. \n", + "Make sure that the right dataset is selected.\n", + "For yhe TabFormer dataset, set\n", + "\n", + "```code\n", + " DATASET = TABFORMER\n", + "```\n", + "and for the Sparkov dataset, set\n", + "\n", + "```code\n", + " DATASET = SPARKOV\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "# Change this to either TABFORMER or SPARKOV\n", + "DATASET = TABFORMER" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "#### Import necessary libraries, packages, and functions" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "# General-purpose libraries and OS handling\n", + "import os\n", + "from typing import Tuple, Dict\n", + "import json\n", + "from collections import defaultdict\n", + "\n", + "# GPU-accelerated libraries (torch, cupy, cudf, rmm)\n", + "import torch\n", + "import cupy\n", + "import cudf\n", + "import rmm\n", + "from rmm.allocators.cupy import rmm_cupy_allocator\n", + "from rmm.allocators.torch import rmm_torch_allocator\n", + "\n", + "# Reinitialize RMM and set allocators to manage memory efficiently on GPU\n", + "rmm.reinitialize(devices=[0], pool_allocator=True, managed_memory=True)\n", + "cupy.cuda.set_allocator(rmm_cupy_allocator)\n", + "torch.cuda.memory.change_current_allocator(rmm_torch_allocator)\n", + "\n", + "# PyTorch and related libraries\n", + "import torch.nn.functional as F\n", + "import torch.nn as nn\n", + "\n", + "# PyTorch Geometric and cuGraph libraries for GNNs and graph handling\n", + "import cugraph_pyg\n", + "from cugraph_pyg.loader import NeighborLoader\n", + "import torch_geometric\n", + "from torch_geometric.nn import SAGEConv\n", + "\n", + "# Enable GPU memory spilling to CPU with cuDF to handle larger datasets\n", + "from cugraph.testing.mg_utils import enable_spilling # noqa: E402\n", + "enable_spilling()\n", + "\n", + "# XGBoost for machine learning model building\n", + "import xgboost as xgb\n", + "\n", + "# Numerical operations with cupy and numpy\n", + "import cupy as cp\n", + "import numpy as np\n", + "\n", + "# Machine learning metrics from sklearn\n", + "from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_auc_score" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Some config parameters for neighborhood sampler and training" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "args = type('', (), {})()\n", + "\n", + "args.out_channels = 2\n", + "args.batch_size = 1024\n", + "args.fan_out = 10\n", + "args.use_cross_weights = True\n", + "args.cross_weights = None" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Path to pre-processed data and directory to save models" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "dateset_name_to_path= defaultdict(lambda: \"../data/TabFormer\")\n", + "\n", + "dateset_name_to_path['TabFormer'] = '../data/TabFormer'\n", + "dateset_name_to_path['Sparkov'] = '../data/Sparkov'\n", + "args.dataset_base_path = dateset_name_to_path[DATASET]\n", + "\n", + "args.dataset_root = os.path.join(args.dataset_base_path, 'gnn')\n", + "args.model_root_dir = os.path.join(args.dataset_base_path, 'models')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Read number of transactions nodes that was saved during preprocessing" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "# Number of transactions nodes were saved in variables.json during training\n", + "with open(os.path.join(args.dataset_base_path, 'variables.json'), 'r') as json_file:\n", + " num_transaction_nodes = json.load(json_file)['NUM_TRANSACTION_NODES']\n", + "\n", + "num_transaction_nodes" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Define a GraphSAGE model" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "class GraphSAGE(torch.nn.Module):\n", + " \"\"\"\n", + " GraphSAGE model for graph-based learning.\n", + "\n", + " This model learns node embeddings by aggregating information from a node's \n", + " neighborhood using multiple graph convolutional layers.\n", + "\n", + " Parameters:\n", + " ----------\n", + " in_channels : int\n", + " The number of input features for each node.\n", + " hidden_channels : int\n", + " The number of hidden units in each layer, controlling the embedding dimension.\n", + " out_channels : int\n", + " The number of output features (or classes) for the final layer.\n", + " n_hops : int\n", + " The number of GraphSAGE layers (or hops) used to aggregate information \n", + " from neighboring nodes.\n", + " dropout_prob : float, optional (default=0.25)\n", + " The probability of dropping out nodes during training for regularization.\n", + " \"\"\"\n", + " def __init__(self, in_channels, hidden_channels, out_channels, n_hops, dropout_prob=0.25):\n", + " super(GraphSAGE, self).__init__()\n", + "\n", + " # list of conv layers\n", + " self.convs = nn.ModuleList()\n", + " # add first conv layer to the list\n", + " self.convs.append(SAGEConv(in_channels, hidden_channels))\n", + " # add the remaining conv layers to the list\n", + " for _ in range(n_hops - 1):\n", + " self.convs.append(SAGEConv(hidden_channels, hidden_channels))\n", + " \n", + " # output layer\n", + " self.fc = nn.Linear(hidden_channels, out_channels) \n", + "\n", + " def forward(self, x, edge_index, return_hidden=False):\n", + "\n", + " for conv in self.convs:\n", + " x = conv(x, edge_index)\n", + " x = F.relu(x)\n", + " x = F.dropout(x, p=0.5, training=self.training)\n", + " \n", + " if return_hidden:\n", + " return x\n", + " else:\n", + " return self.fc(x)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "#### Define a function to train the GraphSAGE model\n", + "__Note__: This function is called a few times if grid search is used to find better hyper-parameters." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "def train_gnn(model, loader, optimizer, criterion)->float:\n", + " \"\"\"\n", + " Trains the GraphSAGE model for one epoch.\n", + "\n", + " Parameters:\n", + " ----------\n", + " model : torch.nn.Module\n", + " The GNN model to be trained.\n", + " loader : tcugraph_pyg.loader.NeighborLoader\n", + " DataLoader that provides batches of graph data for training.\n", + " optimizer : torch.optim.Optimizer\n", + " Optimizer used to update the model's parameters.\n", + " criterion : torch.nn.Module\n", + " Loss function used to calculate the difference between predictions and targets.\n", + "\n", + " Returns:\n", + " -------\n", + " float\n", + " The average training loss over all batches for this epoch.\n", + " \"\"\"\n", + " model.train()\n", + " total_loss = 0\n", + " batch_count = 0\n", + " for batch in loader:\n", + " batch_count += 1\n", + " optimizer.zero_grad()\n", + "\n", + " batch_size = batch.batch_size\n", + " out = model(batch.x[:,:].to(torch.float32), batch.edge_index)[:batch_size]\n", + " y = batch.y[:batch_size].view(-1).to(torch.long)\n", + " loss = criterion(out, y)\n", + " loss.backward()\n", + "\n", + " optimizer.step()\n", + " total_loss += loss.item()\n", + " return total_loss / batch_count\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "#### Define a function to extract node (transaction) embeddings from the second-to-last layer of the GraphSAGE model\n" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "def extract_embeddings(model, loader)->Tuple[torch.Tensor, torch.Tensor]:\n", + " \"\"\"\n", + " Extracts node embeddings produced by the GraphSAGE model.\n", + "\n", + " Parameters:\n", + " ----------\n", + " model : torch.nn.Module\n", + " The model used to generate embeddings, typically a pre-trained neural network.\n", + " loader : cugraph_pyg.loader.NeighborLoader\n", + " NeighborLoader that provides batches of data for embedding extraction.\n", + "\n", + " Returns:\n", + " -------\n", + " Tuple[torch.Tensor, torch.Tensor]\n", + " A tuple containing two tensors:\n", + " - embeddings: A tensor containing embeddings for each input sample in the dataset.\n", + " - labels: A tensor containing the corresponding labels for each sample.\n", + " \"\"\"\n", + " model.eval()\n", + " embeddings = []\n", + " labels = []\n", + " with torch.no_grad():\n", + " for batch in loader:\n", + " batch_size = batch.batch_size\n", + " hidden = model(batch.x[:,:].to(torch.float32), batch.edge_index, return_hidden=True)[:batch_size]\n", + " embeddings.append(hidden) # Keep embeddings on GPU\n", + " labels.append(batch.y[:batch_size].view(-1).to(torch.long))\n", + " embeddings = torch.cat(embeddings, dim=0) # Concatenate embeddings on GPU\n", + " labels = torch.cat(labels, dim=0) # Concatenate labels on GPU\n", + " return embeddings, labels\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "#### Define a function to evaluate the GraphSAGE model\n" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "def evaluate_gnn(model, loader) -> float:\n", + " \"\"\"\n", + " Evaluates the performance of the GraphSAGE model.\n", + "\n", + " Parameters:\n", + " ----------\n", + " model : torch.nn.Module\n", + " The GNN model to be evaluated.\n", + " loader : cugraph_pyg.loader.NeighborLoader\n", + " NeighborLoader that provides batches of data for evaluation.\n", + "\n", + " Returns:\n", + " -------\n", + " float\n", + " The average f1-score computed over all batches.\n", + " \"\"\"\n", + "\n", + " model.eval()\n", + " all_preds = []\n", + " all_labels = []\n", + " total_pos_seen = 0\n", + " with torch.no_grad():\n", + " for batch in loader:\n", + "\n", + " batch_size = batch.batch_size\n", + " out = model(batch.x[:,:].to(torch.float32), batch.edge_index)[:batch_size]\n", + " preds = out.argmax(dim=1)\n", + " y = batch.y[:batch_size].view(-1).to(torch.long)\n", + " \n", + " all_preds.append(preds.cpu().numpy())\n", + " all_labels.append(y.cpu().numpy())\n", + " total_pos_seen += (y.cpu().numpy()==1).sum()\n", + "\n", + " all_preds = np.concatenate(all_preds)\n", + " all_labels = np.concatenate(all_labels)\n", + "\n", + " accuracy = accuracy_score(all_labels, all_preds)\n", + " precision = precision_score(all_labels, all_preds, zero_division=0)\n", + " recall = recall_score(all_labels, all_preds, zero_division=0)\n", + " f1 = f1_score(all_labels, all_preds, zero_division=0)\n", + " # roc_auc = roc_auc_score(all_labels, all_preds)\n", + "\n", + " print(f\"\\nGNN Model Evaluation:\")\n", + " print(f\"Accuracy: {accuracy:.4f}\")\n", + " print(f\"Precision: {precision:.4f}\")\n", + " print(f\"Recall: {recall:.4f}\")\n", + " print(f\"F1 Score: {f1:.4f}\")\n", + " # print(f\"ROC AUC: {roc_auc:.4f}\")\n", + " return f1\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Define a function to compute validation loss GraphSAGE model" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "def validation_loss(model, loader, criterion)->float:\n", + " \"\"\"\n", + " Computes the average validation loss for the GraphSAGE model.\n", + "\n", + " Parameters:\n", + " ----------\n", + " model : torch.nn.Module\n", + " The model for which the validation loss is calculated.\n", + " loader : cugraph_pyg.loader.NeighborLoader\n", + " NeighborLoader that provides batches of validation data.\n", + " criterion : torch.nn.Module\n", + " Loss function used to compute the loss between predictions and targets.\n", + "\n", + " Returns:\n", + " -------\n", + " float\n", + " The average validation loss over all batches.\n", + " \"\"\"\n", + " model.eval()\n", + " with torch.no_grad():\n", + " total_loss = 0\n", + " batch_count = 0\n", + " for batch in loader:\n", + " batch_count += 1\n", + " batch_size = batch.batch_size\n", + " out = model(batch.x[:,:].to(torch.float32), batch.edge_index)[:batch_size]\n", + " y = batch.y[:batch_size].view(-1).to(torch.long)\n", + " loss = criterion(out, y)\n", + " total_loss += loss.item()\n", + " return total_loss / batch_count\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "#### Define a function to train a XGBoost model" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "from torch.utils.dlpack import to_dlpack\n", + "\n", + "def train_xgboost(embeddings, labels)->xgb.Booster:\n", + " \"\"\"\n", + " Trains an XGBoost classifier on the provided embeddings and labels.\n", + "\n", + " Parameters:\n", + " ----------\n", + " embeddings : torch.Tensor\n", + " The input feature embeddings for transaction nodes.\n", + " labels : torch.Tensor\n", + " The target labels (Fraud or Non-fraud) transaction, with the same length as the number of \n", + " rows in `embeddings`.\n", + "\n", + " Returns:\n", + " -------\n", + " xgboost.Booster\n", + " A trained XGBoost model fitted on the provided data.\n", + " \"\"\"\n", + "\n", + " labels_cudf = cudf.Series(cp.from_dlpack(to_dlpack(labels)))\n", + " embeddings_cudf = cudf.DataFrame(cp.from_dlpack(to_dlpack(embeddings)))\n", + "\n", + " # Convert data to DMatrix format for XGBoost on GPU\n", + " dtrain = xgb.DMatrix(embeddings_cudf, label=labels_cudf)\n", + "\n", + " # Set XGBoost parameters for GPU usage\n", + " param = {\n", + " 'max_depth': 6,\n", + " 'learning_rate': 0.2,\n", + " 'objective': 'binary:logistic', # Binary classification\n", + " 'eval_metric': 'logloss',\n", + " 'tree_method': 'hist', # Use GPU\n", + " 'device': 'cuda'\n", + " }\n", + "\n", + " # Train the XGBoost model\n", + " bst = xgb.train(param, dtrain, num_boost_round=100)\n", + " \n", + " return bst\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "tags": [ + "parameters" + ] + }, + "source": [ + "\n", + "#### Define a function to evaluate the XGBoost model\n" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "from cuml.metrics import confusion_matrix\n", + "\n", + "def evaluate_xgboost(bst, embeddings, labels):\n", + " \"\"\"\n", + " Evaluates the performance of a XGBoost model by calculating different metrics.\n", + "\n", + " Parameters:\n", + " ----------\n", + " bst : xgboost.Booster\n", + " The trained XGBoost model to be evaluated.\n", + " embeddings : torch.Tensor\n", + " The input feature embeddings for transaction nodes.\n", + " labels : torch.Tensor\n", + " The target labels (Fraud or Non-fraud) transaction, with the same length as the number of \n", + " rows in `embeddings`.\n", + " Returns:\n", + " -------\n", + " A tuple containing f1-score, recall, precision, accuracy and the confusion matrix\n", + " \"\"\"\n", + "\n", + " # Convert embeddings to cuDF DataFrame\n", + " embeddings_cudf = cudf.DataFrame(cp.from_dlpack(to_dlpack(embeddings)))\n", + " \n", + " # Create DMatrix for the test embeddings\n", + " dtest = xgb.DMatrix(embeddings_cudf)\n", + " \n", + " # Predict using XGBoost on GPU\n", + " preds = bst.predict(dtest)\n", + " pred_labels = (preds > 0.5).astype(int)\n", + "\n", + " # Move labels to CPU for evaluation\n", + " labels_cpu = labels.cpu().numpy()\n", + "\n", + " # Compute evaluation metrics\n", + " accuracy = accuracy_score(labels_cpu, pred_labels)\n", + " precision = precision_score(labels_cpu, pred_labels, zero_division=0)\n", + " recall = recall_score(labels_cpu, pred_labels, zero_division=0)\n", + " f1 = f1_score(labels_cpu, pred_labels, zero_division=0)\n", + " roc_auc = roc_auc_score(labels_cpu, preds)\n", + " conf_mat = confusion_matrix(labels.cpu().numpy(), pred_labels)\n", + " \n", + " return f1, recall, precision, accuracy, conf_mat" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Define a class to stop training once the model stops improving" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [], + "source": [ + "class EarlyStopping:\n", + " \"\"\"\n", + " EarlyStopping class to halt training when a monitored metric stops improving.\n", + " \n", + " Parameters:\n", + " ----------\n", + " patience : int, optional (default=10)\n", + " The number of epochs with no improvement after which training will be stopped.\n", + " min_delta : float, optional (default=0)\n", + " The minimum change in the monitored metric to qualify as an improvement. \n", + " If the change is smaller than `min_delta`, it is considered as no improvement.\n", + " \"\"\"\n", + " def __init__(self, patience=10, min_delta=0):\n", + " \n", + " self.patience = patience\n", + " self.min_delta = min_delta\n", + " self.best_loss = float('inf')\n", + " self.counter = 0\n", + "\n", + " def check_early_stopping(self, val_loss):\n", + "\n", + " if self.best_loss - val_loss > self.min_delta:\n", + " self.best_loss = val_loss\n", + " self.counter = 0 # Reset counter if there's an improvement\n", + " else:\n", + " self.counter += 1 # Increment counter if no improvement\n", + " \n", + " if self.counter >= self.patience:\n", + " return True\n", + " return False\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Define a function to load data and create graph\n", + "* loads edges and create graph using cugraph-pyg\n", + "* loads preprocessed features associated with the graph nodes" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "def load_data(\n", + " dataset_root : str,\n", + " edge_filename: str = 'edges.csv',\n", + " label_filename: str = 'labels.csv',\n", + " node_feature_filename: str = 'features.csv',\n", + " has_edge_feature: bool = False,\n", + " edge_src_col: str = 'src',\n", + " edge_dst_col: str = 'dst',\n", + " edge_att_col: str = 'type'\n", + ") -> Tuple[\n", + " Tuple[torch_geometric.data.FeatureStore, torch_geometric.data.GraphStore],\n", + " Dict[str, torch.Tensor],\n", + " int,\n", + " int,\n", + "]:\n", + " # Load the Graph data\n", + " edge_path = os.path.join(dataset_root, edge_filename)\n", + " edge_data = cudf.read_csv(edge_path, header=None, names=[edge_src_col, edge_dst_col, edge_att_col], dtype=['int32','int32','float'])\n", + " \n", + " num_nodes = max(edge_data[edge_src_col].max(), edge_data[ edge_dst_col].max()) + 1 \n", + " src_tensor = torch.as_tensor(edge_data[edge_src_col], device='cuda')\n", + " dst_tensor = torch.as_tensor(edge_data[edge_dst_col], device='cuda')\n", + "\n", + " \n", + "\n", + " graph_store = cugraph_pyg.data.GraphStore()\n", + " graph_store[(\"n\", \"e\", \"n\"), \"coo\", False, (num_nodes, num_nodes)] = [src_tensor, dst_tensor] \n", + "\n", + " \n", + " edge_feature_store = None\n", + " if has_edge_feature:\n", + " from cugraph_pyg.data import TensorDictFeatureStore\n", + " edge_feature_store = TensorDictFeatureStore()\n", + " edge_attr = torch.as_tensor(edge_data[edge_att_col], device='cuda')\n", + " edge_feature_store[(\"n\", \"e\", \"n\"), \"rel\"] = edge_attr.unsqueeze(1)\n", + " \n", + " \n", + " del(edge_data)\n", + " \n", + " # load the label\n", + " label_path = os.path.join (dataset_root, label_filename)\n", + " label_data = cudf.read_csv(label_path, header=None, dtype=['int32'])\n", + " y_label_tensor = torch.as_tensor(label_data['0'], device='cuda')\n", + " num_classes = label_data['0'].unique().count()\n", + "\n", + " wt_data = None\n", + " if (args.use_cross_weights):\n", + " if (args.cross_weights is None):\n", + " num_labels_rows = label_data.size\n", + " counts = label_data.value_counts()\n", + " wt_data = torch.as_tensor(counts.sum()/counts, device='cuda', dtype=torch.float32)\n", + " wt_data = wt_data/wt_data.sum()\n", + "\n", + " if (num_classes > 2):\n", + " wt_data = wt_data.T\n", + " else:\n", + " wt_data = torch.as_tensor(args.cross_weights, device='cuda')\n", + "\n", + " del(label_data)\n", + " \n", + " # load the features\n", + " feature_path = os.path.join(dataset_root, node_feature_filename)\n", + " feature_data = cudf.read_csv(feature_path)\n", + " \n", + " feature_columns = feature_data.columns\n", + " \n", + " col_tensors = []\n", + " for c in feature_columns:\n", + " t = torch.as_tensor(feature_data[c].values, device='cuda')\n", + " col_tensors.append(t)\n", + "\n", + " x_feature_tensor = torch.stack(col_tensors).T\n", + "\n", + " \n", + " feature_store = cugraph_pyg.data.TensorDictFeatureStore()\n", + " feature_store[\"node\", \"x\"] = x_feature_tensor\n", + " feature_store[\"node\", \"y\"] = y_label_tensor\n", + "\n", + " num_features = len(feature_columns)\n", + " \n", + " return (\n", + " (feature_store, graph_store),\n", + " edge_feature_store,\n", + " num_nodes,\n", + " num_features,\n", + " num_classes,\n", + " wt_data,\n", + " )\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "### Define a function to train the GraphSAGE model for particular values of hyper-parameters." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "def train_model_with_config(params, verbose=False):\n", + "\n", + " data, ef_store, num_nodes, num_features, num_classes, cross_wt_data = load_data(args.dataset_root)\n", + " \n", + " num_folds = params['n_splits'] # Number of folds\n", + " fold_size = num_transaction_nodes // num_folds\n", + "\n", + " # Perform cross-validation\n", + " validation_losses = []\n", + " for k in range(num_folds):\n", + " training_nodes = torch.cat(\n", + " (\n", + " torch.arange(0, k * fold_size).unsqueeze(dim=0),\n", + " torch.arange((k+1) * fold_size, num_transaction_nodes).unsqueeze(dim=0)\n", + " ),\n", + " dim=1\n", + " ).squeeze(0)\n", + "\n", + " validation_nodes = torch.arange(k * fold_size, (k+1) * fold_size)\n", + " \n", + " # Create NeighborLoader for both training and testing (using cuGraph NeighborLoader)\n", + " train_loader = NeighborLoader(\n", + " data,\n", + " num_neighbors=[args.fan_out, args.fan_out],\n", + " batch_size=args.batch_size,\n", + " input_nodes= training_nodes,\n", + " shuffle=True\n", + " )\n", + "\n", + " # Use same graph but different seed nodes\n", + " validation_loader = NeighborLoader(\n", + " data,\n", + " num_neighbors=[args.fan_out, args.fan_out],\n", + " batch_size=args.batch_size,\n", + " input_nodes= validation_nodes,\n", + " shuffle=False\n", + " )\n", + " \n", + " device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n", + " \n", + " # Define the model\n", + " model = GraphSAGE(\n", + " in_channels=num_features,\n", + " hidden_channels=params['hidden_channels'],\n", + " out_channels=args.out_channels,\n", + " n_hops=params['n_hops'],\n", + " dropout_prob=0.25).to(device)\n", + "\n", + "\n", + " # Define optimizer and loss function for GNN\n", + " optimizer = torch.optim.Adam(model.parameters(),\n", + " lr=params['learning_rate'],\n", + " weight_decay=params['weight_decay'])\n", + "\n", + " # criterion = torch.nn.CrossEntropyLoss(\n", + " # weight=cross_wt_data).to(device) # Weighted loss function\n", + " \n", + " criterion = torch.nn.CrossEntropyLoss(\n", + " weight=torch.tensor([0.1, 0.9], dtype=torch.float32)).to(device) # Weighted loss function\n", + "\n", + " # Set up the early stopping object\n", + " early_stopping = EarlyStopping(patience=3, min_delta=0.01)\n", + " \n", + " best_val_loss = float('inf')\n", + " num_epoch_for_best_loss = 0\n", + "\n", + " # Train the GNN model\n", + " for epoch in range(params['num_epochs']):\n", + " train_loss = train_gnn(model, train_loader, optimizer, criterion)\n", + " val_loss = validation_loss(model, validation_loader, criterion)\n", + " if verbose:\n", + " print(f\"Epoch {epoch+1}, Train Loss: {train_loss:.4f}, Val Loss: {val_loss:.4f}\")\n", + "\n", + " # Check early stopping criteria\n", + " if early_stopping.check_early_stopping(val_loss):\n", + " if verbose:\n", + " print(f\"Early stopping triggered at epoch {epoch+1}.\")\n", + " break\n", + "\n", + " # Save the best model based on validation loss\n", + " if val_loss < best_val_loss:\n", + " best_val_loss = val_loss\n", + " num_epoch_for_best_loss = epoch\n", + " # Save validation loss for the current fold\n", + " validation_losses.append(best_val_loss)\n", + " return np.mean(validation_losses), model, num_epoch_for_best_loss" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "#### Parameter grid to search for better hyper-parameters\n", + "\n", + "__Note__: To execute the notebook faster, we commented out the grid search" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "## Uncomment this cell to find the best hyperparameters in the parameter grid\n", + "# from sklearn.model_selection import ParameterGrid\n", + "# # Define the hyperparameter grid\n", + "# param_grid = {\n", + "# 'n_splits': [5],\n", + "# 'n_hops': [1, 2],\n", + "# 'learning_rate': [0.005, 0.01],\n", + "# 'hidden_channels': [32, 64],\n", + "# 'num_epochs': [8, 16],\n", + "# 'weight_decay': [1e-5],\n", + " \n", + "# }\n", + "# grid = list(ParameterGrid(param_grid))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Search for better hyper-parameters" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [], + "source": [ + "## Uncomment this cell to find the best hyperparameters in the parameter grid\n", + "# best_val_loss = float('inf')\n", + "# epoch = 0\n", + "# best_params = None\n", + "# for params in grid:\n", + "# val_loss, _, epoch = train_model_with_config(params, verbose=False)\n", + "# if val_loss < best_val_loss:\n", + "# best_params = params\n", + "# best_val_loss = val_loss" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [], + "source": [ + "# best_params" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [], + "source": [ + "# Comment out this cell to train on new dataset \n", + "best_params = {\n", + " 'n_hops': 1,\n", + " 'learning_rate': 0.005,\n", + " 'hidden_channels': 32,\n", + " 'num_epochs': 16,\n", + " 'weight_decay': 1e-5, \n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Train and save the GraphSAGE model" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "data, ef_store, num_nodes, num_features, num_classes, cross_wt_data = load_data(args.dataset_root)\n", + "\n", + "# Train on entire dataset\n", + "train_loader = NeighborLoader(\n", + " data,\n", + " num_neighbors=[args.fan_out, args.fan_out],\n", + " batch_size=args.batch_size,\n", + " input_nodes= torch.arange(num_transaction_nodes),\n", + " shuffle=True\n", + ")\n", + "\n", + "device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n", + " \n", + "# Define the model\n", + "model = GraphSAGE(\n", + " in_channels=num_features,\n", + " hidden_channels=best_params['hidden_channels'],\n", + " out_channels=args.out_channels,\n", + " n_hops=best_params['n_hops'],\n", + " dropout_prob=0.25).to(device)\n", + "\n", + "\n", + "# Define optimizer and loss function for GNN\n", + "optimizer = torch.optim.Adam(model.parameters(),\n", + " lr=best_params['learning_rate'],\n", + " weight_decay=best_params['weight_decay'])\n", + "\n", + "\n", + "criterion = torch.nn.CrossEntropyLoss(\n", + " weight=torch.tensor([0.1, 0.9], dtype=torch.float32)).to(device) # Weighted loss function\n", + "\n", + "# Set up the early stopping object\n", + "early_stopping = EarlyStopping(patience=3, min_delta=0.01)\n", + "\n", + "best_train_loss = float('inf')\n", + "\n", + "# Train the GNN model\n", + "\n", + "for epoch in range(best_params['num_epochs']):\n", + " train_loss = train_gnn(model, train_loader, optimizer, criterion)\n", + " \n", + " # Check early stopping criteria\n", + " if early_stopping.check_early_stopping(train_loss):\n", + " print(f\"Early stopping triggered at epoch {epoch+1}.\")\n", + " break\n", + "\n", + " # Save the best model based on validation loss\n", + " if train_loss < best_train_loss:\n", + " best_train_loss = train_loss\n", + " if not os.path.exists(args.model_root_dir):\n", + " os.makedirs(args.model_root_dir)\n", + " torch.save(model, os.path.join(args.model_root_dir, 'node_embedder.pth'))\n", + "\n", + " print(f\"Model saved at epoch {epoch+1} with training loss {best_train_loss:.4f}.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Train the XGBoost model based on embeddings produced by the GraphSAGE model" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# NeighborLoader for training data\n", + "\n", + "data, ef_store, num_nodes, num_features, num_classes, cross_wt_data = load_data(args.dataset_root)\n", + "\n", + "train_loader = NeighborLoader(\n", + " data,\n", + " num_neighbors=[args.fan_out, args.fan_out],\n", + " batch_size=args.batch_size,\n", + " input_nodes= torch.arange(num_transaction_nodes),\n", + " shuffle=True\n", + ")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [], + "source": [ + "# Set the device to GPU if available; otherwise, default to CPU\n", + "device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n", + "\n", + "# Extract embeddings from the second-to-last layer and keep them on GPU\n", + "embeddings, labels = extract_embeddings(model, train_loader)\n", + "\n", + "# Train an XGBoost model on the extracted embeddings (on GPU)\n", + "bst = train_xgboost(embeddings.to(device), labels.to(device))\n", + " \n", + "xgb_model_path = os.path.join(args.model_root_dir, 'embedding_based_xgb_model.json')\n", + "\n", + "if not os.path.exists(os.path.dirname(xgb_model_path)):\n", + " os.makedirs(os.path.dirname(xgb_model_path))\n", + "\n", + "bst.save_model(xgb_model_path)\n", + " " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Evaluation the model on unseen data" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Load and prepare test data\n" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n", + "test_path = os.path.join(args.dataset_base_path, 'xgb/test.csv')\n", + "test_data = cudf.read_csv(test_path)\n", + "\n", + "X = torch.tensor(test_data.iloc[:, :-1].values).to(torch.float32)\n", + "y = torch.tensor(test_data.iloc[:, -1].values).to(torch.long)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Extract embeddings of the transactions using the GraphSAGE model" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "model.eval()\n", + "f1_value = 0.0\n", + "with torch.no_grad():\n", + " test_embeddings = model(\n", + " X.to(device), torch.tensor([[], []], dtype=torch.int).to(device), return_hidden=True)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Evaluate the XGBoost model" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "f1, recall, precision, accuracy, conf_mat = evaluate_xgboost(bst, test_embeddings, y)\n", + "\n", + "print(f\"\\nXGBoost Evaluation:\")\n", + "print(f\"Accuracy: {accuracy:.4f}\")\n", + "print(f\"Precision: {precision:.4f}\")\n", + "print(f\"Recall: {recall:.4f}\")\n", + "print(f\"F1 Score: {f1:.4f}\")\n", + "print('Confusion Matrix:', conf_mat)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Copyright and License\n", + "
\n", + "Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved.\n", + "\n", + "
\n", + "\n", + " Licensed under the Apache License, Version 2.0 (the \"License\");\n", + " you may not use this file except in compliance with the License.\n", + " You may obtain a copy of the License at\n", + " \n", + " http://www.apache.org/licenses/LICENSE-2.0\n", + " \n", + " Unless required by applicable law or agreed to in writing, software\n", + " distributed under the License is distributed on an \"AS IS\" BASIS,\n", + " WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + " See the License for the specific language governing permissions and\n", + " limitations under the License." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "simple_env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.10" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/ai-credit-fraud-workflow/notebooks/train_xgboost.ipynb b/ai-credit-fraud-workflow/notebooks/train_xgboost.ipynb new file mode 100644 index 0000000..cee3319 --- /dev/null +++ b/ai-credit-fraud-workflow/notebooks/train_xgboost.ipynb @@ -0,0 +1,501 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Train an XGBoost model\n", + "#### Goals\n", + "\n", + "* Build only an XGBoost model without leveraging a GNN.\n", + "* Establish a baseline performance using the XGBoost model.\n", + "\n", + "__NOTE__: This XGBoost model does not leverage embeddings from the GNN (GraphSAGE) model." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Dataset names" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "# Name of the datasets to choose from\n", + "TABFORMER = \"TabFormer\"\n", + "SPARKOV = \"Sparkov\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Select the dataset to train the model on\n", + "__Note__: This notebook works for both __TabFormer__ and __Sparkov__ dataset. \n", + "Make sure that the right dataset is selected.\n", + "For yhe TabFormer dataset, set\n", + "\n", + "```code\n", + " DATASET = TABFORMER\n", + "```\n", + "and for the Sparkov dataset, set\n", + "\n", + "```code\n", + " DATASET = SPARKOV\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "# Change this to either TABFORMER or SPARKOV\n", + "DATASET = TABFORMER" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Import necessary libraries, packages, and functions" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "import os\n", + "from collections import defaultdict\n", + "\n", + "import cudf\n", + "import cupy\n", + "import xgboost as xgb\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.metrics import auc, f1_score, precision_score, recall_score\n", + "\n", + "from cuml.metrics import confusion_matrix, precision_recall_curve, roc_auc_score\n", + "from cuml.metrics.accuracy import accuracy_score" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Path to pre-processed data and directory to save models" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "dateset_name_to_path= defaultdict(lambda: \"../data/TabFormer\")\n", + "\n", + "dateset_name_to_path['TabFormer'] = '../data/TabFormer'\n", + "dateset_name_to_path['Sparkov'] = '../data/Sparkov'\n", + "dataset_dir = dateset_name_to_path[DATASET]\n", + "xgb_data_dir = os.path.join(dataset_dir, 'xgb')\n", + "models_dir = os.path.join(dataset_dir, 'models')\n", + "model_file_name = 'xgboost_model.json'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Load and prepare training and validation data" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "train_data_path = os.path.join(xgb_data_dir, \"training.csv\")\n", + "df = cudf.read_csv(train_data_path)\n", + "\n", + "# Target column\n", + "target_col_name = df.columns[-1]\n", + "\n", + "# Split the dataframe into features (X) and labels (y)\n", + "y = df[target_col_name]\n", + "X = df.drop(target_col_name, axis=1)\n", + "\n", + "# Split data into trainand testing sets\n", + "from cuml.model_selection import train_test_split\n", + "X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2)\n", + "\n", + "# Convert the training and test data to DMatrix\n", + "dtrain = xgb.DMatrix(data=X_train, label=y_train)\n", + "deval = xgb.DMatrix(data=X_val, label=y_val)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Parameter grid to search for the best hyper-parameters for the input data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import itertools\n", + "\n", + "# Define the parameter grid for manual search\n", + "param_grid = {\n", + " 'max_depth': [5, 6],\n", + " 'learning_rate': [0.3, 0.4, 0.45],\n", + " 'n_estimators': [100, 150],\n", + " 'gamma': [0, 0.1],\n", + "}\n", + "\n", + "# Generate all combinations of hyperparameters\n", + "param_combinations = list(itertools.product(*param_grid.values()))\n", + "\n", + "# Print all combinations of hyperparameters (optional)\n", + "print(\"Total number of parameter combinations:\", len(param_combinations))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Grid search for the best hyperparameters" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "best_score = float(\"inf\") # Initialize best score\n", + "best_params = None # To store best hyperparameters\n", + "\n", + "for params_comb in param_combinations:\n", + " \n", + " # Create a dictionary of parameters\n", + " params = {\n", + " 'max_depth': params_comb[0],\n", + " 'learning_rate': params_comb[1],\n", + " 'gamma': params_comb[3],\n", + " 'eval_metric': 'logloss',\n", + " 'objective': 'binary:logistic', # For binary classification\n", + " 'tree_method': 'hist', # GPU support\n", + " 'device': 'cuda'\n", + " }\n", + "\n", + " # Train the model using xgb.train and the Booster\n", + " evals = [(dtrain, 'train'), (deval, 'eval')]\n", + " bst = xgb.train(params, dtrain, num_boost_round=params_comb[2], evals=evals, \n", + " early_stopping_rounds=10, verbose_eval=False)\n", + " \n", + " # Get the evaluation score (logloss) on the validation set\n", + " score = bst.best_score # The logloss score (or use other eval_metric)\n", + "\n", + " # Update the best parameters if the current model is better\n", + " if score < best_score:\n", + " best_score = score\n", + " best_params = params\n", + " best_num_boost_round = bst.best_iteration" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "best_params, best_score, best_num_boost_round" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Train the model with the best hyperparameters" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "# Train the final model using the best parameters and best number of boosting rounds\n", + "dtrain = xgb.DMatrix(data=X, label=y)\n", + "final_model = xgb.train(best_params, dtrain, num_boost_round=best_num_boost_round)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "# Save the best model\n", + "if not os.path.exists(models_dir):\n", + " os.makedirs(models_dir)\n", + "final_model.save_model(os.path.join(models_dir, model_file_name))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "___\n", + "### Evaluate the model on the same unseen data that is used for testing GNN based XGBoost" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Load the saved model" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "# Load the model from the file\n", + "best_model_loaded = xgb.Booster()\n", + "best_model_loaded.load_model(os.path.join(models_dir, model_file_name))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Load and prepare unseen test data" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "test_data_path = os.path.join(xgb_data_dir, \"test.csv\")\n", + "\n", + "test_df = cudf.read_csv(test_data_path)\n", + "\n", + "dnew = xgb.DMatrix(test_df.drop(target_col_name, axis=1))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Predict targets" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "# Make predictions\n", + "y_pred_prob = best_model_loaded.predict(dnew)\n", + "y_pred = (y_pred_prob >= 0.5).astype(int)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Compute metrics to evaluate model performance" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "y_test = test_df[target_col_name].values \n", + "\n", + "# Accuracy\n", + "accuracy = accuracy_score(y_test, y_pred)\n", + "print(f'Accuracy: {accuracy:.4f}')\n", + "\n", + "# Confusion Matrix\n", + "conf_mat = confusion_matrix(y_test, y_pred)\n", + "print('Confusion Matrix:')\n", + "print(conf_mat)\n", + "\n", + "# ROC AUC Score\n", + "r_auc = roc_auc_score(y_test, y_pred_prob)\n", + "print(f'ROC AUC Score: {r_auc:.4f}')\n", + "\n", + "y_test = cupy.asnumpy(y_test)\n", + "# Precision\n", + "precision = precision_score(y_test, y_pred)\n", + "print(f'Precision: {precision:.4f}')\n", + "\n", + "# Recall\n", + "recall = recall_score(y_test, y_pred)\n", + "print(f'Recall: {recall:.4f}')\n", + "\n", + "# F1 Score\n", + "f1 = f1_score(y_test, y_pred)\n", + "print(f'F1 Score: {f1:.4f}')\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Plot Precision-Recall curve\n", + "* A Precision-Recall Curve shows the trade-off between precision and recall for a model at various thresholds, helping assess performance, especially on imbalanced data" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "# Compute Precision, Recall, and thresholds\n", + "precision, recall, thresholds = precision_recall_curve(y_test, y_pred_prob)\n", + "\n", + "# Compute the Area Under the Curve (AUC) for Precision-Recall\n", + "pr_auc = auc(recall, precision)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Plot precision-recall curve" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "plt.figure()\n", + "plt.plot(recall, precision, label=f'PR AUC = {pr_auc:.2f}')\n", + "plt.xlabel('Recall')\n", + "plt.ylabel('Precision')\n", + "plt.title('Precision-Recall Curve')\n", + "plt.legend(loc='best')\n", + "plt.grid(True)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Plot precision-recall curve with thresholds" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.figure()\n", + "plt.plot(thresholds, precision[:-1], label=\"Precision\")\n", + "plt.plot(thresholds, recall[:-1], label=\"Recall\")\n", + "plt.xlabel(\"Threshold\")\n", + "plt.ylabel(\"Score\")\n", + "plt.title(\"Precision-Recall Curve with Thresholds\")\n", + "plt.legend()\n", + "plt.grid()\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [], + "source": [ + "# One can choose optimal threshold based on the F1 score" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Copyright and License\n", + "
\n", + "Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved.\n", + "\n", + "
\n", + "\n", + " Licensed under the Apache License, Version 2.0 (the \"License\");\n", + " you may not use this file except in compliance with the License.\n", + " You may obtain a copy of the License at\n", + " \n", + " http://www.apache.org/licenses/LICENSE-2.0\n", + " \n", + " Unless required by applicable law or agreed to in writing, software\n", + " distributed under the License is distributed on an \"AS IS\" BASIS,\n", + " WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + " See the License for the specific language governing permissions and\n", + " limitations under the License." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "mamba_env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.10" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/ai-credit-fraud-workflow/requirements.txt b/ai-credit-fraud-workflow/requirements.txt new file mode 100644 index 0000000..ab749d9 --- /dev/null +++ b/ai-credit-fraud-workflow/requirements.txt @@ -0,0 +1,2 @@ +matplotlib==3.9.2 +category-encoders==2.6.4

tjZEv*_hX8@ZP*ZpE>X8+YEI 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 I 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� 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