From b39f4f2192d3f2640de09936ee1671f94567265a Mon Sep 17 00:00:00 2001 From: Soji Adeshina Date: Sun, 12 Jul 2020 23:56:14 -0700 Subject: [PATCH] reorganize project structure, improve README --- README.md | 58 +- deployment/architecture.png | Bin 0 -> 61858 bytes ...-detection-using-machine-learning.template | 804 ------------------ ...raud-detection-using-machine-learning.yaml | 661 ++++++++++++++ .../model-invocation}/index.py | 17 +- source/notebooks/requirements.in | 12 + source/notebooks/requirements.txt | 48 ++ .../notebooks/sagemaker_fraud_detection.ipynb | 9 +- source/notebooks/setup.py | 10 + source/notebooks/src/package/__init__.py | 0 source/notebooks/src/package/config.py | 21 + .../src/package/generate_endpoint_traffic.py | 63 ++ source/notebooks/src/package/utils.py | 13 + 13 files changed, 896 insertions(+), 820 deletions(-) create mode 100644 deployment/architecture.png delete mode 100644 deployment/fraud-detection-using-machine-learning.template create mode 100644 deployment/fraud-detection-using-machine-learning.yaml rename source/{fraud_detection => lambda/model-invocation}/index.py (90%) create mode 100644 source/notebooks/requirements.in create mode 100644 source/notebooks/requirements.txt create mode 100644 source/notebooks/setup.py create mode 100644 source/notebooks/src/package/__init__.py create mode 100644 source/notebooks/src/package/config.py create mode 100644 source/notebooks/src/package/generate_endpoint_traffic.py create mode 100644 source/notebooks/src/package/utils.py diff --git a/README.md b/README.md index 8364b49..b62c636 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,59 @@ -## Fraud Detection Using Machine Learning +# Fraud Detection using Machine Learning -Setup end to end demo architecture for predicting fraud events with Machine Learning using Amazon SageMaker +With businesses moving online, fraud and abuse in online systems is constantly increasing as well. Traditionally, rule-based fraud detection systems are used to combat online fraud, but these rely on a static set of rules created by human experts. This project uses machine learning to create models for fraud detection that are dynamic, self-improving and maintainable. Importantly, they can scale with the online business. + +Specifically, we show how to use Amazon SageMaker to train supervised and unsupervised machine learning models on historical transactions, so that they can predict the likelihood of incoming transactions being fraudulent or not. We also show how to deploy the models, once trained, to a REST API that can be integrated into an existing business software infracture. This project includes a demonstration of this process using a public, anonymized credit card transactions [dataset provided by ULB](https://www.kaggle.com/mlg-ulb/creditcardfraud), but can be easily modified to work with custom labelled or unlaballed data provided as a relational table in csv format. + +## Getting Started + +To get started quickly, use the following quick-launch link to launch a CloudFormation Stack create form and follow the instructions below to deploy the resources in this project. + +| Region | Stack | +| ---- | ---- | +|US West (Oregon) | [](https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/create/review?templateURL=https://s3.amazonaws.com/sagemaker-solutions-us-west-2/Fraud-detection-using-machine-learning/deployment/fraud-detection-using-machine-learning.yaml&stackName=SageMaker-Fraud-Machine-Learning) | + + +### Additional Instructions + +* On the stack creation page, enter a name in the **Model and Data Bucket Name** field under S3 configurations and in the **Results Bucket Name**, check the box to acknowledge creation of IAM resources, and click **Create Stack**. This should trigger the creation of the CloudFormation stack. + +* Once the stack is created, go to the Outputs tab and click on the *SageMakerNotebook* link. This will open up the jupyter notebook in a SageMaker Notebook instance where you can run the code in the notebook. + +## Architecture + +The project architecture deployed by the cloud formation template is shown here. + +![](deployment/architecture.png) + +## Project Description +The project uses Amazon SageMaker to train both a supervised and an unsupervised machine learning models, which are then deployed using Amazon Sagemaker-managed endpoints. + +If you have labels for your data, for example if some of the transactions have been annotated as fraudulent and some as legitimate, then you can train a supervised learning model to learn to discern the two classes. In this project, we provide a recipe to train a gradient boosted decision tree model using [XGBoost on Amazon SageMaker](https://docs.aws.amazon.com/sagemaker/latest/dg/xgboost.html). The supervised model training process also handles the common issue of working with highly imbalanced data in fraud detection problems. The project addresses this issue into two ways by 1) implementing data upsampling using the "imbalanced-learn" package, and 2) using scale position weight to control the balance of positive and negative weights. + +If you don't have labelled data or if you want to augment your supervised model predictions with an anomaly score from an unsupervised model, then the project also trains a [RandomCutForest](https://docs.aws.amazon.com/sagemaker/latest/dg/randomcutforest.html) model using Amazon SageMaker. The RandomCutForest algorithm is trained on the entire dataset, without labels, and takes advantage of the highly imbalanced nature of fraud datasets, to predict higher anomaly scores for the fraudulent transactions in the dataset. + +Both of the trained models are deployed to Amazon SageMaker managed real-time endpoints that host the models and can be invoked to provide model predictions for new transactions. + +The model training and endpoint deployment is orchestrated by running a [jupyter notebook](source/notebooks/sagemaker_fraud_detection.ipynb) on a SageMaker Notebook instance. The jupyter notebook runs a demonstration of the project using the aforementioned anonymized credit card dataset that is automatically downloaded to the Amazon S3 Bucket created when you launch the solution. However, the notebook can be modified to run the project on a custom dataset in S3. The notebook instance also contains some example code that shows how to invoke the REST API for inference. + +In order to encapsulate the project as a stand-alone microservice, Amazon API Gateway is used to provide a REST API, that is backed by an AWS Lambda function. The Lambda function runs the [code](https://github.com/awslabs/fraud-detection-using-machine-learning/blob/master/source/fraud_detection/index.py) to preprocess incoming transactions, invoke sagemaker endpoints, merge results from both endpoints if necessary, store the model inputs and model predictions in S3 via Kinesis Firehose, and provide a response to the client. + + +## Contents + +* `deployment/` + * `fraud-detection-using-machine-learning.yaml`: Creates AWS CloudFormation Stack for solution +* `source/` + * `fraud-detection/` + * `index.py`: Lambda function script for invoking SageMaker endpoints for inference + * `notebooks/` + * `generate_endpoint_traffic.py`: Custom script to show how to send transaction traffic to REST API for inference + * `sagemaker_fraud_detection.ipynb`: Orchestrates the solution. Trains the models and deploys the trained model + * `setup/` + * `on-start.sh`: Bash script to setup sagemaker notebook environment with necessary dependencies ## License -This library is licensed under the Apache 2.0 License. +This project is licensed under the Apache-2.0 License. + + diff --git a/deployment/architecture.png b/deployment/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..04d694cdde85c94e2a1119e9778a9ab9b8d87ed3 GIT binary patch literal 61858 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 Iy#4t906|5msQ>@~ literal 0 HcmV?d00001 diff --git a/deployment/fraud-detection-using-machine-learning.template b/deployment/fraud-detection-using-machine-learning.template deleted file mode 100644 index 13e8aea..0000000 --- a/deployment/fraud-detection-using-machine-learning.template +++ /dev/null @@ -1,804 +0,0 @@ -{ - "AWSTemplateFormatVersion": "2010-09-09", - "Description": "(SO0056) - fraud-detection-using-machine-learning: Solution for predicting fraud events with ML using Amazon SageMaker. Version 2", - "Parameters": { - "S3BucketName1": { - "Type": "String", - "Description": "New bucket for storing the Amazon SageMaker model and training data." - }, - "S3BucketName2": { - "Type": "String", - "Description": "New bucket for storing processed events for visualization features." - }, - "KinesisFirehosePrefix": { - "Type": "String", - "Default": "fraud-detection/firehose/", - "Description": "Kinesis Firehose prefix for delivery of processed events." - } - }, - "Metadata": { - "AWS::CloudFormation::Interface": { - "ParameterGroups": [{ - "Label": { - "default": "Amazon S3 Bucket Configuration" - }, - "Parameters": ["S3BucketName1", "S3BucketName2"] - }, - { - "Label": { - "default": "Amazon Kinesis Firehose Configuration" - }, - "Parameters": ["KinesisFirehosePrefix"] - } - ], - "ParameterLabels": { - "S3BucketName1": { - "default": "Model and Data Bucket Name" - }, - "S3BucketName2": { - "default": "Results Bucket Name" - }, - "KinesisFirehosePrefix": { - "default": "Kinesis Firehose S3 Prefix" - } - } - } - }, - "Mappings": { - "Function": { - "FraudDetection": { - "S3Bucket": "%%BUCKET_NAME%%", - "S3Key": "fraud-detection-using-machine-learning/%%VERSION%%/fraud_detection.zip" - } - }, - "Notebook": { - "FraudDetection": { - "S3Bucket": "%%BUCKET_NAME%%", - "S3Key": "/fraud-detection-using-machine-learning/%%VERSION%%/notebooks/sagemaker_fraud_detection.ipynb" - } - }, - "Script": { - "Install": { - "S3Bucket": "%%BUCKET_NAME%%", - "S3Key": "/fraud-detection-using-machine-learning/%%VERSION%%/notebooks/on-start.sh" - }, - "GenerateTraffic": { - "S3Bucket": "%%BUCKET_NAME%%", - "S3Key": "/fraud-detection-using-machine-learning/%%VERSION%%/notebooks/generate_endpoint_traffic.py" - } - } - }, - "Resources": { - "S3Bucket1": { - "Type": "AWS::S3::Bucket", - "Properties": { - "BucketName": { - "Ref": "S3BucketName1" - }, - "PublicAccessBlockConfiguration": { - "BlockPublicAcls": true, - "BlockPublicPolicy": true, - "IgnorePublicAcls": true, - "RestrictPublicBuckets": true - }, - "BucketEncryption": { - "ServerSideEncryptionConfiguration": [{ - "ServerSideEncryptionByDefault": { - "SSEAlgorithm": "AES256" - } - }] - } - }, - "Metadata": { - "cfn_nag": { - "rules_to_suppress": [{ - "id": "W35", - "reason": "Configuring logging requires supplying an existing customer S3 bucket to store logs" - }, - { - "id": "W51", - "reason": "Default policy works fine" - }] - } - } - }, - "S3Bucket2": { - "Type": "AWS::S3::Bucket", - "Properties": { - "BucketName": { - "Ref": "S3BucketName2" - }, - "PublicAccessBlockConfiguration": { - "BlockPublicAcls": true, - "BlockPublicPolicy": true, - "IgnorePublicAcls": true, - "RestrictPublicBuckets": true - }, - "BucketEncryption": { - "ServerSideEncryptionConfiguration": [{ - "ServerSideEncryptionByDefault": { - "SSEAlgorithm": "AES256" - } - }] - } - }, - "Metadata": { - "cfn_nag": { - "rules_to_suppress": [{ - "id": "W35", - "reason": "Configuring logging requires supplying an existing customer S3 bucket to store logs" - }, - { - "id": "W51", - "reason": "Default policy works fine" - }] - } - } - }, - "BasicNotebookInstance": { - "Type": "AWS::SageMaker::NotebookInstance", - "Properties": { - "InstanceType": "ml.t2.medium", - "NotebookInstanceName": "FraudDetectionNotebookInstance", - "RoleArn": { - "Fn::GetAtt": [ - "NotebookInstanceExecutionRole", - "Arn" - ] - }, - "LifecycleConfigName": { - "Fn::GetAtt": [ - "BasicNotebookInstanceLifecycleConfig", - "NotebookInstanceLifecycleConfigName" - ] - } - } - }, - "BasicNotebookInstanceLifecycleConfig": { - "Type": "AWS::SageMaker::NotebookInstanceLifecycleConfig", - "DependsOn": [ - "S3Bucket1" - ], - "Properties": { - "OnStart": [{ - "Content": { - "Fn::Base64": { - "Fn::Join": [";", ["cd /home/ec2-user/SageMaker", - { - "Fn::Join": ["", ["aws s3 cp s3://", { - "Fn::Join": ["-", [{ - "Fn::FindInMap": ["Notebook", "FraudDetection", "S3Bucket"] - }, { - "Ref": "AWS::Region" - }]] - }, { - "Fn::FindInMap": ["Notebook", "FraudDetection", "S3Key"] - }, " ."]] - }, - { - "Fn::Join": ["", ["sed -i 's/fraud-detection-end-to-end-demo/", { - "Ref": "S3BucketName1" - }, "/g' sagemaker_fraud_detection.ipynb"]] - }, - { - "Fn::Join": ["", ["aws s3 cp s3://", { - "Fn::Join": ["-", [{ - "Fn::FindInMap": ["Script", "GenerateTraffic", "S3Bucket"] - }, { - "Ref": "AWS::Region" - }]] - }, { - "Fn::FindInMap": ["Script", "GenerateTraffic", "S3Key"] - }, " ."]] - }, - { - "Fn::Join": ["", ["aws s3 cp s3://", { - "Fn::Join": ["-", [{ - "Fn::FindInMap": ["Script", "Install", "S3Bucket"] - }, { - "Ref": "AWS::Region" - }]] - }, { - "Fn::FindInMap": ["Script", "Install", "S3Key"] - }, " ."]] - }, - { - "Fn::Join": ["", ["sed -i 's/fraud-detection-api-placeholder/", { - "Ref": "RESTAPIGateway" - }, "/g' generate_endpoint_traffic.py"]] - }, - "bash ./on-start.sh" - ]] - } - } - }] - } - }, - "NotebookInstanceExecutionRole": { - "Type": "AWS::IAM::Role", - "Properties": { - "AssumeRolePolicyDocument": { - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Principal": { - "Service": [ - "sagemaker.amazonaws.com" - ] - }, - "Action": [ - "sts:AssumeRole" - ] - }] - } - } - }, - "NotebookInstanceIAMPolicy": { - "Type": "AWS::IAM::Policy", - "Properties": { - "PolicyName": "sm-notebook-instance-policy", - "PolicyDocument": { - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Action": [ - "s3:GetBucketLocation", - "s3:ListBucket", - "s3:GetObject", - "s3:PutObject", - "s3:DeleteObject" - ], - "Resource": [{ - "Fn::Join": ["", ["arn:aws:s3:::", { - "Ref": "S3BucketName1" - }]] - }, - { - "Fn::Join": ["", ["arn:aws:s3:::", { - "Ref": "S3BucketName1" - }, "/*"]] - }, - { - "Fn::Join": ["", ["arn:aws:s3:::", { - "Fn::Join": ["-", [{ - "Fn::FindInMap": ["Notebook", "FraudDetection", "S3Bucket"] - }, { - "Ref": "AWS::Region" - }]] - }, "/*"]] - } - ] - }, - { - "Effect": "Allow", - "Action": [ - "sagemaker:CreateTrainingJob", - "sagemaker:DescribeTrainingJob", - "sagemaker:CreateModel", - "sagemaker:DescribeModel", - "sagemaker:DeleteModel", - "sagemaker:CreateEndpoint", - "sagemaker:CreateEndpointConfig", - "sagemaker:DescribeEndpoint", - "sagemaker:DescribeEndpointConfig", - "sagemaker:DeleteEndpoint", - "sagemaker:DeleteEndpointConfig", - "sagemaker:InvokeEndpoint" - ], - "Resource": [{ - "Fn::Join": ["", ["arn:aws:sagemaker:", { - "Ref": "AWS::Region" - }, ":", { - "Ref": "AWS::AccountId" - }, ":*"]] - }] - }, - { - "Effect": "Allow", - "Action": [ - "ecr:GetDownloadUrlForLayer", - "ecr:BatchGetImage", - "ecr:BatchCheckLayerAvailability" - ], - "Resource": [{ - "Fn::Join": ["", ["arn:aws:ecr:", { - "Ref": "AWS::Region" - }, ":", { - "Ref": "AWS::AccountId" - }, ":repository/*"]] - }] - }, - { - "Effect": "Allow", - "Action": [ - "ec2:CreateVpcEndpoint", - "ec2:DescribeRouteTables" - ], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": [ - "cloudwatch:PutMetricData", - "cloudwatch:GetMetricData", - "cloudwatch:GetMetricStatistics", - "cloudwatch:ListMetrics" - ], - "Resource": [{ - "Fn::Join": ["", ["arn:aws:cloudwatch:", { - "Ref": "AWS::Region" - }, ":", { - "Ref": "AWS::AccountId" - }, ":*"]] - }] - }, - { - "Effect": "Allow", - "Action": [ - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:DescribeLogStreams", - "logs:GetLogEvents", - "logs:PutLogEvents" - ], - "Resource": { - "Fn::Join": ["", ["arn:aws:logs:", { - "Ref": "AWS::Region" - }, ":", { - "Ref": "AWS::AccountId" - }, ":log-group:/aws/sagemaker/*"]] - } - }, - { - "Effect": "Allow", - "Action": [ - "iam:PassRole" - ], - "Resource": [{ - "Fn::GetAtt": ["NotebookInstanceExecutionRole", "Arn"] - } - - ], - "Condition": { - "StringEquals": { - "iam:PassedToService": "sagemaker.amazonaws.com" - } - } - }, - { - "Effect": "Allow", - "Action": [ - "iam:GetRole" - ], - "Resource": [{ - "Fn::GetAtt": ["NotebookInstanceExecutionRole", "Arn"] - }] - }, - { - "Effect": "Allow", - "Action": [ - "lambda:InvokeFunction" - ], - "Resource": [{ - "Fn::GetAtt": ["LambdaFunction", "Arn"] - }] - }, - { - "Effect": "Allow", - "Action": [ - "execute-api:Invoke" - ], - "Resource": [{ - "Fn::Join": [ - "", - [ - "arn:aws:execute-api:", - { "Ref": "AWS::Region" }, - ":", - { "Ref": "AWS::AccountId" }, - ":", - { "Ref": "RESTAPIGateway" }, - "/*/POST/*" - ] - ] - }] - } - ] - }, - "Roles": [{ - "Ref": "NotebookInstanceExecutionRole" - }] - }, - "Metadata": { - "cfn_nag": { - "rules_to_suppress": [{ - "id": "W12", - "reason": "This policy needs to have * resource because some of the resources are created dynamically and some of its actions are * resource actions" - }] - } - } - }, - "LambdaFunction": { - "Type": "AWS::Lambda::Function", - "Properties": { - "Handler": "index.lambda_handler", - "FunctionName": "fraud-detection-event-processor", - "Role": { - "Fn::GetAtt": [ - "LambdaExecutionRole", - "Arn" - ] - }, - "Code": { - "S3Bucket": { - "Fn::Join": ["-", [{ - "Fn::FindInMap": ["Function", "FraudDetection", "S3Bucket"] - }, { - "Ref": "AWS::Region" - }]] - }, - "S3Key": { - "Fn::FindInMap": ["Function", "FraudDetection", "S3Key"] - } - }, - "Runtime": "python3.6" - } - }, - "LambdaExecutionRole": { - "Type": "AWS::IAM::Role", - "Properties": { - "AssumeRolePolicyDocument": { - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Principal": { - "Service": [ - "lambda.amazonaws.com" - ] - }, - "Action": [ - "sts:AssumeRole" - ] - }] - }, - "Path": "/", - "Policies": [{ - "PolicyName": "root", - "PolicyDocument": { - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Action": [ - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:PutLogEvents" - ], - "Resource": { - "Fn::Join": ["", ["arn:aws:logs:", { - "Ref": "AWS::Region" - }, ":", { - "Ref": "AWS::AccountId" - }, ":log-group:/aws/lambda/*"]] - } - }, - { - "Effect": "Allow", - "Action": [ - "sagemaker:InvokeEndpoint" - ], - "Resource": [ - "arn:aws:sagemaker:*:*:endpoint/*" - ] - }, - { - "Effect": "Allow", - "Action": [ - "firehose:PutRecord", - "firehose:PutRecordBatch" - ], - "Resource": { - "Fn::GetAtt": [ - "KinesisFirehoseDeliveryStream", - "Arn" - ] - } - } - - ] - } - }] - }, - "Metadata": { - "cfn_nag": { - "rules_to_suppress": [{ - "id": "W11", - "reason": "This role needs to have * resource in it's policy because resource names are created dynamically and some of its actions are * resource actions" - }] - } - } - }, - "KinesisFirehoseDeliveryStream": { - "Type": "AWS::KinesisFirehose::DeliveryStream", - "Properties": { - "DeliveryStreamName": "fraud-detection-firehose-stream", - "DeliveryStreamType": "DirectPut", - "S3DestinationConfiguration": { - "BucketARN": { - "Fn::GetAtt": [ - "S3Bucket2", - "Arn" - ] - }, - "Prefix": { - "Ref": "KinesisFirehosePrefix" - }, - "BufferingHints": { - "IntervalInSeconds": 60, - "SizeInMBs": 100 - }, - "CompressionFormat": "GZIP", - "EncryptionConfiguration": { - "NoEncryptionConfig": "NoEncryption" - }, - "RoleARN": { - "Fn::GetAtt": [ - "FirehoseDeliveryIAMRole", - "Arn" - ] - } - } - }, - "DependsOn": [ - "FirehoseDeliveryIAMPolicy" - ] - }, - "FirehoseDeliveryIAMRole": { - "Type": "AWS::IAM::Role", - "Properties": { - "AssumeRolePolicyDocument": { - "Version": "2012-10-17", - "Statement": [{ - "Sid": "", - "Effect": "Allow", - "Principal": { - "Service": "firehose.amazonaws.com" - }, - "Action": [ - "sts:AssumeRole" - ] - }] - } - } - }, - "FirehoseDeliveryIAMPolicy": { - "Type": "AWS::IAM::Policy", - "Properties": { - "PolicyName": "fraud-detection-firehose-policy", - "PolicyDocument": { - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Action": [ - "s3:AbortMultipartUpload", - "s3:GetBucketLocation", - "s3:GetObject", - "s3:ListBucket", - "s3:ListBucketMultipartUploads", - "s3:PutObject" - ], - "Resource": [{ - "Fn::Join": ["", ["arn:aws:s3:::", { - "Ref": "S3BucketName2" - }]] - }, - { - "Fn::Join": ["", ["arn:aws:s3:::", { - "Ref": "S3BucketName2" - }, "/", { - "Ref": "KinesisFirehosePrefix" - }, "*"]] - } - ] - }] - }, - "Roles": [{ - "Ref": "FirehoseDeliveryIAMRole" - }] - }, - "Metadata": { - "cfn_nag": { - "rules_to_suppress": [{ - "id": "W12", - "reason": "This policy needs to have * resource because some of its actions are * resource actions" - }] - } - } - }, - "RESTAPIGateway" : { - "Type" : "AWS::ApiGateway::RestApi", - "Properties" : { - "Description" : "A REST API that can be used to invoke the Lambda function that triggers predictions.", - "Name" : "model-invocation-api", - "EndpointConfiguration": { - "Types": ["REGIONAL"] - } - } - }, - "APIGatewayCloudWatchLogGroup": { - "Type" : "AWS::Logs::LogGroup", - "Properties" : { - "LogGroupName" : {"Fn::Join": ["/", ["/aws/apigateway/AccessLogs", {"Ref": "RESTAPIGateway"}, "prod"]]}, - "RetentionInDays" : 3653 - } - }, - "APIGatewayCloudWatchRole": { - "Type": "AWS::IAM::Role", - "Properties": { - "AssumeRolePolicyDocument": { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Principal": { - "Service": [ - "apigateway.amazonaws.com" - ] - }, - "Action": "sts:AssumeRole" - } - ] - }, - "Path": "/", - "ManagedPolicyArns": [ - "arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs" - ] - } - }, - "APIGatewayAccount": { - "Type": "AWS::ApiGateway::Account", - "Properties": { - "CloudWatchRoleArn": { - "Fn::GetAtt": [ - "APIGatewayCloudWatchRole", - "Arn" - ] - } - }, - "DependsOn": ["RESTAPIGateway"] - }, - "LambdaAPIPermission" : { - "Type": "AWS::Lambda::Permission", - "Properties": { - "Action": "lambda:InvokeFunction", - "FunctionName": {"Fn::GetAtt": ["LambdaFunction", "Arn"]}, - "Principal": "apigateway.amazonaws.com", - "SourceArn": { - "Fn::Join": [ - "", - [ - "arn:aws:execute-api:", - { "Ref": "AWS::Region" }, - ":", - { "Ref": "AWS::AccountId" }, - ":", - { "Ref": "RESTAPIGateway" }, - "/*/POST/*" - ] - ] - } - } - }, - "RESTInvocationResource" :{ - "Type" : "AWS::ApiGateway::Resource", - "Properties" : { - "ParentId" : { "Fn::GetAtt": ["RESTAPIGateway", "RootResourceId"] }, - "PathPart" : "invocations", - "RestApiId" : {"Ref" : "RESTAPIGateway"} - } - }, - "POSTMethod": { - "Type": "AWS::ApiGateway::Method", - "Properties": { - "RestApiId": { - "Ref": "RESTAPIGateway" - }, - "ResourceId": { - "Ref": "RESTInvocationResource" - }, - "HttpMethod": "POST", - "AuthorizationType": "AWS_IAM", - "Integration": { - "Type": "AWS", - "IntegrationHttpMethod": "POST", - "Uri": {"Fn::Join" : ["", ["arn:aws:apigateway:", {"Ref": "AWS::Region"}, ":lambda:path/2015-03-31/functions/", {"Fn::GetAtt": ["LambdaFunction", "Arn"]}, "/invocations"]]}, - "IntegrationResponses": [{ - "ResponseTemplates": { - "application/json": "" - }, - "StatusCode": 200 - }, { - "SelectionPattern": "^not found.*", - "ResponseTemplates": { - "application/json": "{}" - }, - "StatusCode": 404 - }], - "PassthroughBehavior": "WHEN_NO_TEMPLATES", - "RequestTemplates": { - "application/json": - "{\"data\": $input.json('$.data'),\"metadata\": $input.json('$.metadata'),\"model\": \"$input.params('model')\"}" - } - }, - "MethodResponses": [{ - "ResponseModels": { - "application/json": "Empty" - }, - "StatusCode": 200 - }, { - "ResponseModels": { - "application/json": "Empty" - }, - "StatusCode": 404 - }], - "RequestParameters": { - "method.request.querystring.model": false - } - } - }, - "RestApiDeployment": { - "Type": "AWS::ApiGateway::Deployment", - "Properties": { - "StageDescription": { - "AccessLogSetting": { - "DestinationArn" : {"Fn::GetAtt": ["APIGatewayCloudWatchLogGroup", "Arn"]}, - "Format" : { - "Fn::Join": [",", ["{\"requestId\":\"$context.requestId\"", - "\"ip\": \"$context.identity.sourceIp\"", - "\"caller\":\"$context.identity.caller\"", - "\"user\":\"$context.identity.user\"", - "\"requestTime\":\"$context.requestTime\"", - "\"httpMethod\":\"$context.httpMethod\"", - "\"resourcePath\":\"$context.resourcePath\"", - "\"status\":\"$context.status\"", - "\"protocol\":\"$context.protocol\"", - "\"responseLength\":\"$context.responseLength\"}" - ] - ] - } - } - }, - "RestApiId": {"Ref": "RESTAPIGateway"}, - "StageName": "prod" - }, - "DependsOn": ["POSTMethod"] - } - }, - "Outputs": { - "BasicNotebookInstanceId": { - "Value": { - "Ref": "BasicNotebookInstance" - } - }, - "firehoseDeliveryStreamArn": { - "Description": "Firehose Delivery Stream ARN", - "Value": { - "Fn::GetAtt": [ - "KinesisFirehoseDeliveryStream", - "Arn" - ] - } - }, - "firehoseDeliveryRoleArn": { - "Description": "Firehose Delivery Role ARN", - "Value": { - "Fn::GetAtt": [ - "FirehoseDeliveryIAMRole", - "Arn" - ] - } - }, - "RestApiId" : { - "Value": {"Ref": "RESTAPIGateway"} - } - } -} \ No newline at end of file diff --git a/deployment/fraud-detection-using-machine-learning.yaml b/deployment/fraud-detection-using-machine-learning.yaml new file mode 100644 index 0000000..324a68b --- /dev/null +++ b/deployment/fraud-detection-using-machine-learning.yaml @@ -0,0 +1,661 @@ +AWSTemplateFormatVersion: 2010-09-09 +Description: >- + (SO0056) - fraud-detection-using-machine-learning: Solution for predicting + fraud events with ML using Amazon SageMaker. Version 3 +Parameters: + ModelDataBucketName: + Type: String + Description: New bucket for storing the Amazon SageMaker model and training data. + OutputBucketName: + Type: String + Description: Bucket to store the predictions to be visualized using Quicksight. + KinesisFirehosePrefix: + Type: String + Default: fraud-detection/firehose/ + Description: Kinesis Firehose prefix for delivery of processed events. + SolutionsS3BucketName: + Description: Enter the name of the S3 bucket for the solution + Type: String + Default: "sagemaker-solutions" +Metadata: + 'AWS::CloudFormation::Interface': + ParameterGroups: + - Label: + default: Amazon S3 Bucket Configuration + Parameters: + - ModelDataBucketName + - OutputBucketName + - Label: + default: Amazon Kinesis Firehose Configuration + Parameters: + - KinesisFirehosePrefix + - Label: + default: Solution Configuration Parameters + Parameters: + - SolutionsS3BucketName + ParameterLabels: + SolutionsS3BucketName: + default: SageMaker Solution Bucket Base Name + ModelDataBucketName: + default: Model and Data Bucket Name + OutputBucketName: + default: Prediction Output Bucket Name + KinesisFirehosePrefix: + default: Kinesis Firehose S3 Prefix +Mappings: + Function: + FraudDetection: + S3Key: fraud-detection-using-machine-learning/build/model_invocation.zip + Notebook: + FraudDetection: + S3Key: >- + /fraud-detection-using-machine-learning/notebooks/sagemaker_fraud_detection.ipynb + Script: + GenerateTraffic: + S3Key: >- + /fraud-detection-using-machine-learning/notebooks/generate_endpoint_traffic.py + KibanaDashboard: + S3Key: /fraud-detection-using-machine-learning/notebooks/dashboard.json +Resources: + ModelDataBucket: + Type: 'AWS::S3::Bucket' + Properties: + BucketName: !Ref ModelDataBucketName + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + LoggingConfiguration: + DestinationBucketName: !Ref LogBucket + LogFilePrefix: fraud-model-data-bucket/ + Metadata: + cfn_nag: + rules_to_suppress: + - id: W35 + reason: >- + Configuring logging requires supplying an existing customer S3 + bucket to store logs + - id: W51 + reason: Default policy works fine + S3Bucket2: + Type: 'AWS::S3::Bucket' + Properties: + BucketName: !Ref OutputBucketName + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + LoggingConfiguration: + DestinationBucketName: !Ref LogBucket + LogFilePrefix: fraud-output-bucket/ + Metadata: + cfn_nag: + rules_to_suppress: + - id: W35 + reason: >- + Configuring logging requires supplying an existing customer S3 + bucket to store logs + - id: W51 + reason: Default policy works fine + LogBucket: + Type: 'AWS::S3::Bucket' + DeletionPolicy: Retain + Properties: + AccessControl: LogDeliveryWrite + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + Metadata: + cfn_nag: + rules_to_suppress: + - id: W35 + reason: this is the log bucket + LogBucketPolicy: + Type: 'AWS::S3::BucketPolicy' + Properties: + Bucket: !Ref LogBucket + PolicyDocument: + Version: 2012-10-17 + Statement: + - Sid: AWSCloudTrailAclCheck + Effect: Allow + Principal: + Service: cloudtrail.amazonaws.com + Action: 's3:GetBucketAcl' + Resource: !GetAtt + - LogBucket + - Arn + - Sid: AWSCloudTrailWrite + Effect: Allow + Principal: + Service: cloudtrail.amazonaws.com + Action: 's3:PutObject' + Resource: !Join + - '' + - - 'arn:aws:s3:::' + - !Ref LogBucket + - /AWSLogs/ + - !Ref 'AWS::AccountId' + - /* + Condition: + StringEquals: + 's3:x-amz-acl': bucket-owner-full-control + BasicNotebookInstance: + Type: 'AWS::SageMaker::NotebookInstance' + Properties: + InstanceType: ml.t3.medium + NotebookInstanceName: FraudDetectionNotebookInstance + RoleArn: !GetAtt + - NotebookInstanceExecutionRole + - Arn + LifecycleConfigName: !GetAtt + - BasicNotebookInstanceLifecycleConfig + - NotebookInstanceLifecycleConfigName + DependsOn: + - NotebookInstanceIAMPolicy + Metadata: + cfn_nag: + rules_to_suppress: + - id: W1201 + reason: Solution does not have KMS encryption enabled by default + BasicNotebookInstanceLifecycleConfig: + Type: 'AWS::SageMaker::NotebookInstanceLifecycleConfig' + DependsOn: + - ModelDataBucket + Properties: + OnCreate: + - Content: + Fn::Base64: !Sub | + set -e + # perform following actions as ec2-user + sudo -u ec2-user -i <> .env + echo "AWS_REGION=${AWS::Region}" >> .env + echo "SAGEMAKER_IAM_ROLE=${NotebookInstanceExecutionRole.Arn}" >> .env + echo "SOLUTIONS_S3_BUCKET=${SolutionsS3BucketName}-${AWS::Region}" >> .env + echo "MODEL_DATA_S3_BUCKET=${ModelDataBucketName}" >> .env + echo "REST_API_GATEWAY=${RESTAPIGateway}" >> .env + EOF + OnStart: + - Content: + Fn::Base64: !Sub | + set -e + # perform following actions as ec2-user + sudo -u ec2-user -i <- + This policy needs to have * resource because some of the resources + are created dynamically and some of its actions are * resource + actions + LambdaFunction: + Type: 'AWS::Lambda::Function' + Properties: + Handler: index.lambda_handler + FunctionName: fraud-detection-event-processor + Role: !GetAtt + - LambdaExecutionRole + - Arn + Code: + S3Bucket: !Sub "${SolutionsS3BucketName}-${AWS::Region}" + S3Key: !FindInMap + - Function + - FraudDetection + - S3Key + Runtime: python3.6 + LambdaExecutionRole: + Type: 'AWS::IAM::Role' + Properties: + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Action: + - 'sts:AssumeRole' + Path: / + Policies: + - PolicyName: root + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: + - 'logs:CreateLogGroup' + - 'logs:CreateLogStream' + - 'logs:PutLogEvents' + Resource: !Join + - '' + - - 'arn:aws:logs:' + - !Ref 'AWS::Region' + - ':' + - !Ref 'AWS::AccountId' + - ':log-group:/aws/lambda/*' + - Effect: Allow + Action: + - 'sagemaker:InvokeEndpoint' + Resource: + - 'arn:aws:sagemaker:*:*:endpoint/*' + - Effect: Allow + Action: + - 'firehose:PutRecord' + - 'firehose:PutRecordBatch' + Resource: !GetAtt + - KinesisFirehoseDeliveryStream + - Arn + Metadata: + cfn_nag: + rules_to_suppress: + - id: W11 + reason: >- + This role needs to have * resource in it's policy because resource + names are created dynamically and some of its actions are * + resource actions + KinesisFirehoseDeliveryStream: + Type: 'AWS::KinesisFirehose::DeliveryStream' + Properties: + DeliveryStreamName: fraud-detection-firehose-stream + DeliveryStreamType: DirectPut + S3DestinationConfiguration: + BucketARN: !GetAtt + - S3Bucket2 + - Arn + Prefix: !Ref KinesisFirehosePrefix + BufferingHints: + IntervalInSeconds: 60 + SizeInMBs: 100 + CompressionFormat: GZIP + EncryptionConfiguration: + NoEncryptionConfig: NoEncryption + RoleARN: !GetAtt + - FirehoseDeliveryIAMRole + - Arn + DependsOn: + - FirehoseDeliveryIAMPolicy + FirehoseDeliveryIAMRole: + Type: 'AWS::IAM::Role' + Properties: + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Sid: '' + Effect: Allow + Principal: + Service: firehose.amazonaws.com + Action: + - 'sts:AssumeRole' + FirehoseDeliveryIAMPolicy: + Type: 'AWS::IAM::Policy' + Properties: + PolicyName: fraud-detection-firehose-policy + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: + - 's3:AbortMultipartUpload' + - 's3:GetBucketLocation' + - 's3:GetObject' + - 's3:ListBucket' + - 's3:ListBucketMultipartUploads' + - 's3:PutObject' + Resource: + - !Join + - '' + - - 'arn:aws:s3:::' + - !Ref OutputBucketName + - !Join + - '' + - - 'arn:aws:s3:::' + - !Ref OutputBucketName + - / + - !Ref KinesisFirehosePrefix + - '*' + Roles: + - !Ref FirehoseDeliveryIAMRole + Metadata: + cfn_nag: + rules_to_suppress: + - id: W12 + reason: >- + This policy needs to have * resource because some of its actions + are * resource actions + RESTAPIGateway: + Type: 'AWS::ApiGateway::RestApi' + Properties: + Description: >- + A REST API that can be used to invoke the Lambda function that triggers + predictions. + Name: model-invocation-api + EndpointConfiguration: + Types: + - REGIONAL + APIGatewayCloudWatchLogGroup: + Type: 'AWS::Logs::LogGroup' + Properties: + LogGroupName: !Join + - / + - - /aws/apigateway/AccessLogs + - !Ref RESTAPIGateway + - prod + RetentionInDays: 365 + APIGatewayCloudWatchRole: + Type: 'AWS::IAM::Role' + Properties: + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + Service: + - apigateway.amazonaws.com + Action: 'sts:AssumeRole' + Path: / + ManagedPolicyArns: + - >- + arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs + APIGatewayAccount: + Type: 'AWS::ApiGateway::Account' + Properties: + CloudWatchRoleArn: !GetAtt + - APIGatewayCloudWatchRole + - Arn + DependsOn: + - RESTAPIGateway + LambdaAPIPermission: + Type: 'AWS::Lambda::Permission' + Properties: + Action: 'lambda:InvokeFunction' + FunctionName: !GetAtt + - LambdaFunction + - Arn + Principal: apigateway.amazonaws.com + SourceArn: !Join + - '' + - - 'arn:aws:execute-api:' + - !Ref 'AWS::Region' + - ':' + - !Ref 'AWS::AccountId' + - ':' + - !Ref RESTAPIGateway + - /*/POST/* + RESTInvocationResource: + Type: 'AWS::ApiGateway::Resource' + Properties: + ParentId: !GetAtt + - RESTAPIGateway + - RootResourceId + PathPart: invocations + RestApiId: !Ref RESTAPIGateway + POSTMethod: + Type: 'AWS::ApiGateway::Method' + Properties: + RestApiId: !Ref RESTAPIGateway + ResourceId: !Ref RESTInvocationResource + HttpMethod: POST + AuthorizationType: AWS_IAM + Integration: + Type: AWS + IntegrationHttpMethod: POST + Uri: !Join + - '' + - - 'arn:aws:apigateway:' + - !Ref 'AWS::Region' + - ':lambda:path/2015-03-31/functions/' + - !GetAtt + - LambdaFunction + - Arn + - /invocations + IntegrationResponses: + - ResponseTemplates: + application/json: '' + StatusCode: 200 + - SelectionPattern: ^not found.* + ResponseTemplates: + application/json: '{}' + StatusCode: 404 + PassthroughBehavior: WHEN_NO_TEMPLATES + RequestTemplates: + application/json: >- + {"data": $input.json('$.data'),"metadata": + $input.json('$.metadata'),"model": "$input.params('model')"} + MethodResponses: + - ResponseModels: + application/json: Empty + StatusCode: 200 + - ResponseModels: + application/json: Empty + StatusCode: 404 + RequestParameters: + method.request.querystring.model: false + RestApiDeployment: + Type: 'AWS::ApiGateway::Deployment' + Properties: + StageDescription: + AccessLogSetting: + DestinationArn: !GetAtt + - APIGatewayCloudWatchLogGroup + - Arn + Format: !Join + - ',' + - - '{"requestId":"$context.requestId"' + - '"ip": "$context.identity.sourceIp"' + - '"caller":"$context.identity.caller"' + - '"user":"$context.identity.user"' + - '"requestTime":"$context.requestTime"' + - '"httpMethod":"$context.httpMethod"' + - '"resourcePath":"$context.resourcePath"' + - '"status":"$context.status"' + - '"protocol":"$context.protocol"' + - '"responseLength":"$context.responseLength"}' + RestApiId: !Ref RESTAPIGateway + StageName: prod + DependsOn: + - POSTMethod + Metadata: + cfn_nag: + rules_to_suppress: + - id: W68 + reason: Resource not associated with an AWS::ApiGateway::UsagePlan for now +Outputs: + JupyterInterface: + Description: "Open Jupyter IDE. This authenticates you against Jupyter." + Value: !Sub "https://console.aws.amazon.com/sagemaker/home?region=${AWS::Region}#/notebook-instances/openNotebook/FraudDetectionNotebookInstance?view=classic" + SageMakerNotebook: + Description: "Open Jupyter notebook kick off model training" + Value: !Sub "https://frauddetectionnotebookinstance.notebook.${AWS::Region}.sagemaker.aws/notebooks/source/notebooks/sagemaker_fraud_detection.ipynb" + FirehoseDeliveryStreamArn: + Description: Firehose Delivery Stream ARN + Value: !GetAtt + - KinesisFirehoseDeliveryStream + - Arn + FirehoseDeliveryRoleArn: + Description: Firehose Delivery Role ARN + Value: !GetAtt + - FirehoseDeliveryIAMRole + - Arn + RestApiId: + Value: !Ref RESTAPIGateway diff --git a/source/fraud_detection/index.py b/source/lambda/model-invocation/index.py similarity index 90% rename from source/fraud_detection/index.py rename to source/lambda/model-invocation/index.py index 4ffa49a..4faa4d7 100644 --- a/source/fraud_detection/index.py +++ b/source/lambda/model-invocation/index.py @@ -14,9 +14,6 @@ ############################################################################## import json import os -import random -import datetime -import re import logging import boto3 @@ -32,10 +29,10 @@ def lambda_handler(event, context): data_payload = event.get('data', None) assert data_payload, "Payload did not include a data field!" model_choice = event.get('model', None) - valid_models = set(['anomaly_detector', 'fraud_classifier']) + valid_models = {'anomaly_detector', 'fraud_classifier'} if model_choice: assert model_choice in valid_models, "The requested model, {}, was not a valid model name {}".format(model_choice, valid_models) - models = set([model_choice]) if model_choice else valid_models + models = {model_choice} if model_choice else valid_models output = {} if 'anomaly_detector' in models: @@ -44,7 +41,7 @@ def lambda_handler(event, context): if 'fraud_classifier' in models: output["fraud_classifier"] = get_fraud_prediction(data_payload) - success = store_data_prediction(output, metadata) + store_data_prediction(output, metadata) return output @@ -67,7 +64,7 @@ def get_fraud_prediction(data, threshold=0.5): Body=data) pred_proba = json.loads(response['Body'].read().decode()) prediction = 0 if pred_proba < threshold else 1 - # Note: XGBoost returns a float as a prediction, a linear learner would require different handling. + logger.info("classification pred_proba: {}, prediction: {}".format(pred_proba, prediction)) return {"pred_proba": pred_proba, "prediction": prediction} @@ -84,5 +81,7 @@ def store_data_prediction(output_dict, metadata): record = ','.join(metadata + [str(fraud_pred), str(anomaly_score)]) + '\n' success = firehose.put_record(DeliveryStreamName=firehose_delivery_stream, Record={'Data': record}) - logger.info("Record logged: {}".format(record)) - return success + if success: + logger.info("Record logged: {}".format(record)) + else: + logger.warning("Record delivery failed for record: {}".format(record)) diff --git a/source/notebooks/requirements.in b/source/notebooks/requirements.in new file mode 100644 index 0000000..475c8b3 --- /dev/null +++ b/source/notebooks/requirements.in @@ -0,0 +1,12 @@ +imbalanced-learn +aws_requests_auth +matplotlib +scikit-learn +pandas +sagemaker +boto3 +seaborn +docutils==0.14 +awscli +botocore==1.17.4 +python-dotenv diff --git a/source/notebooks/requirements.txt b/source/notebooks/requirements.txt new file mode 100644 index 0000000..0b849c5 --- /dev/null +++ b/source/notebooks/requirements.txt @@ -0,0 +1,48 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile requirements.in +# +aws-requests-auth==0.4.3 # via -r requirements.in +awscli==1.18.81 # via -r requirements.in +boto3==1.14.4 # via -r requirements.in, sagemaker +botocore==1.17.4 # via -r requirements.in, awscli, boto3, s3transfer +certifi==2020.4.5.2 # via requests +chardet==3.0.4 # via requests +colorama==0.4.3 # via awscli +cycler==0.10.0 # via matplotlib +docutils==0.14 # via -r requirements.in, awscli, botocore +idna==2.9 # via requests +imbalanced-learn==0.7.0 # via -r requirements.in +importlib-metadata==1.6.1 # via sagemaker +jmespath==0.10.0 # via boto3, botocore +joblib==0.15.1 # via imbalanced-learn, scikit-learn +kiwisolver==1.2.0 # via matplotlib +matplotlib==3.2.1 # via -r requirements.in, seaborn +numpy==1.18.5 # via imbalanced-learn, matplotlib, pandas, sagemaker, scikit-learn, scipy, seaborn +packaging==20.4 # via sagemaker +pandas==1.0.4 # via -r requirements.in, seaborn +protobuf3-to-dict==0.1.5 # via sagemaker +protobuf==3.12.2 # via protobuf3-to-dict, sagemaker +pyasn1==0.4.8 # via rsa +pyparsing==2.4.7 # via matplotlib, packaging +python-dateutil==2.8.1 # via botocore, matplotlib, pandas +python-dotenv==0.13.0 # via -r requirements.in +pytz==2020.1 # via pandas +pyyaml==5.3.1 # via awscli +requests==2.23.0 # via aws-requests-auth +rsa==3.4.2 # via awscli +s3transfer==0.3.3 # via awscli, boto3 +sagemaker==1.64.1 # via -r requirements.in +scikit-learn==0.23.1 # via -r requirements.in, imbalanced-learn +scipy==1.4.1 # via imbalanced-learn, sagemaker, scikit-learn, seaborn +seaborn==0.10.1 # via -r requirements.in +six==1.15.0 # via cycler, packaging, protobuf, protobuf3-to-dict, python-dateutil +smdebug-rulesconfig==0.1.4 # via sagemaker +threadpoolctl==2.1.0 # via scikit-learn +urllib3==1.25.9 # via botocore, requests +zipp==3.1.0 # via importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/source/notebooks/sagemaker_fraud_detection.ipynb b/source/notebooks/sagemaker_fraud_detection.ipynb index 56a6658..99792a9 100644 --- a/source/notebooks/sagemaker_fraud_detection.ipynb +++ b/source/notebooks/sagemaker_fraud_detection.ipynb @@ -601,7 +601,8 @@ "source": [ "We can now show how we could use both of these models in a production system, using HTTP requests to an AWS Lambda function that invokes both the unsupervised and the supervised SageMaker endpoints.\n", "\n", - "We create a background thread that will constantly create HTTP requests to invoke the Lambda, using our test data as input. See the included `generate_endpoint_traffic.py` file to see how that is done. The output will be logged to Kinesis, and you can also observe it in the Lambda function's CloudWatch logs." + "We create a background thread that will constantly create HTTP requests to invoke the Lambda, using our test data as input. See the included `generate_endpoint_traffic.py` file to see how that is done.\n", + "The output will be logged to an S3 bucket through Kinesis, and you can also observe it in the Lambda function's CloudWatch logs." ] }, { @@ -611,10 +612,10 @@ "outputs": [], "source": [ "from threading import Thread\n", - "from generate_endpoint_traffic import generate_traffic\n", + "from package.generate_endpoint_traffic import generate_traffic\n", "\n", "thread = Thread(target = generate_traffic, args=[np.copy(X_test)])\n", - "thread.start()" + "thread.start()\n" ] }, { @@ -884,7 +885,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.5" + "version": "3.6.10" } }, "nbformat": 4, diff --git a/source/notebooks/setup.py b/source/notebooks/setup.py new file mode 100644 index 0000000..683efe4 --- /dev/null +++ b/source/notebooks/setup.py @@ -0,0 +1,10 @@ +from distutils.core import setup + + +setup( + name='package', + version='1.0', + description="A package to organize the solution's code.", + package_dir={'': 'src'}, + packages=['package'], +) diff --git a/source/notebooks/src/package/__init__.py b/source/notebooks/src/package/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/source/notebooks/src/package/config.py b/source/notebooks/src/package/config.py new file mode 100644 index 0000000..ac189b1 --- /dev/null +++ b/source/notebooks/src/package/config.py @@ -0,0 +1,21 @@ +from dotenv import load_dotenv +import os +from pathlib import Path + +from package import utils + +current_folder = utils.get_current_folder(globals()) +env_location = '../../../../.env' +dotenv_filepath = Path(current_folder, env_location).resolve() +assert dotenv_filepath.exists(), "Could not find .env file at {}".format(str(dotenv_filepath)) + +load_dotenv() + +AWS_ACCOUNT_ID = os.environ['AWS_ACCOUNT_ID'] +AWS_REGION = os.environ['AWS_REGION'] +SAGEMAKER_IAM_ROLE = os.environ['SAGEMAKER_IAM_ROLE'] +STACK_NAME = os.environ['STACK_NAME'] +SOLUTIONS_S3_BUCKET = os.environ['SOLUTIONS_S3_BUCKET'] + +MODEL_DATA_S3_BUCKET = os.environ['MODEL_DATA_S3_BUCKET'] +REST_API_GATEWAY = os.environ['REST_API_GATEWAY'] diff --git a/source/notebooks/src/package/generate_endpoint_traffic.py b/source/notebooks/src/package/generate_endpoint_traffic.py new file mode 100644 index 0000000..54ee75d --- /dev/null +++ b/source/notebooks/src/package/generate_endpoint_traffic.py @@ -0,0 +1,63 @@ +""" +Handles generating traffic and creating the ElasticSearch index and dashboard. +""" +import time +import re +import datetime +import random + +import requests +from aws_requests_auth.boto_utils import BotoAWSRequestsAuth +import numpy as np +from scipy.stats import poisson + +from package import config + +def generate_metadata(): + """ + Generates medatadata for the HTTP request: a randomized source and a timestamp. + """ + millisecond_regex = r'\.\d+' + timestamp = re.sub(millisecond_regex, '', str(datetime.datetime.now())) + source = random.choice(['Mobile', 'Web', 'Store']) + result = [timestamp, 'random_id', source] + + return result + + +def get_data_payload(test_array): + return {'data':','.join(map(str, test_array)), + 'metadata': generate_metadata()} + + +def generate_traffic(X_test): + """ + Using a feature array as input + """ + while True: + # NB: The shuffle will mutate the X_test array in-place, so ensure + # you're working with a copy if you intend to use the calling argument + # array elsewhere. + np.random.shuffle(X_test) + for example in X_test: + data_payload = get_data_payload(example) + invoke_endpoint(data_payload) + # We invoke the function according to a shifted Poisson distribution + # to simulate data arriving at random intervals + time.sleep(poisson.rvs(1, size=1)[0] + np.random.rand() / 100) + + +def invoke_endpoint(payload): + """ + We get credentials from the IAM role of the notebook instance, + then use them to create a signed request to the API Gateway + """ + auth = BotoAWSRequestsAuth(aws_host="{}.execute-api.{}.amazonaws.com".format( + config.REST_API_GATEWAY, config.AWS_REGION), + aws_region=config.AWS_REGION, + aws_service='execute-api') + + invoke_url = "https://{}.execute-api.{}.amazonaws.com/prod/invocations".format( + config.REST_API_GATEWAY, config.AWS_REGION) + + requests.post(invoke_url, json=payload, auth=auth) diff --git a/source/notebooks/src/package/utils.py b/source/notebooks/src/package/utils.py new file mode 100644 index 0000000..0c263fc --- /dev/null +++ b/source/notebooks/src/package/utils.py @@ -0,0 +1,13 @@ +from pathlib import Path +import os + + +def get_current_folder(global_variables): + # if calling from a file + if "__file__" in global_variables: + current_file = Path(global_variables["__file__"]) + current_folder = current_file.parent.resolve() + # if calling from a notebook + else: + current_folder = Path(os.getcwd()) + return current_folder