diff --git a/.gitignore b/.gitignore index 5839562a6..5d9ac71fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ __pycache__/ .vscode build/* -*.zip workshop/*/.ipynb_checkpoints workshop/1-Personalization/interactions.csv workshop/1-Personalization/items.csv @@ -16,4 +15,8 @@ demo.md generators/*.gz /csvs/ .unotes/* -!workshop/5-Conversational/RetailDemoStore_Lex.zip \ No newline at end of file +!workshop/5-Conversational/RetailDemoStore_Lex.zip +*.zip +workshop/data/* +workshop/datagenerator/* +workshop/requirements.txt diff --git a/generators/README.md b/generators/README.md new file mode 100644 index 000000000..50042e3be --- /dev/null +++ b/generators/README.md @@ -0,0 +1,17 @@ +# User Data Generator + +[generate_users_json.py](./generate_users_json.py) generates a set of users for the Retail Demo Store. + +These user profiles are used in the following ways: + +* The [Users service](../src/users) provides login services to the user profiles that this creates for the Retail Demo Store +* Workshops which need to generate simulated user behavior data can use the datagenerator library to create simulated events for these user profiles after they are created. This provides realistic and consistent data across all integrated tools in the Retail Demo Store. + +## datagenerator Library + +The datagenerator library is a Python library that provides the following functions: + +* A pool of randomly generated users see [./datagenerator/users.py](./datagenerator/users.py) +* The ability to specify a set of user behavior funnels and to then generate events that can be sent to Amazon Personalize, Segment, or Amplitude (see [./datagenerator/file.py](./datagenerator/file.py), [./datagenerator/amplitude.py](./datagenerator/amplitude.py), and [./datagenerator/segment.py](./datagenerator/segment.py)). + +For a working example of the event generator features, see [3.5-Amplitude-Performance-Metrics.ipynb](../workshop/3-Experimentation/3.5-Amplitude-Performance-Metrics.ipynb) diff --git a/generators/datagenerator/__init__.py b/generators/datagenerator/__init__.py new file mode 100644 index 000000000..f7666892e --- /dev/null +++ b/generators/datagenerator/__init__.py @@ -0,0 +1,4 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 + +aws_datagenerator_version = '1.8.0' diff --git a/generators/datagenerator/amplitude.py b/generators/datagenerator/amplitude.py new file mode 100644 index 000000000..2e6b9103d --- /dev/null +++ b/generators/datagenerator/amplitude.py @@ -0,0 +1,80 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 + +import datagenerator +import json +import requests +import yaml + +# Amplitude event support +# This follows the Amplitude V2 HTTP Bulk API spec, here: +# https://help.amplitude.com/hc/en-us/articles/360032842391-HTTP-API-V2 +# +# These classes accept a user, platform, and general event properties and map them +# into an Amplitude API compatible represenation. + +class AmplitudeEvent: + def __init__(self, timestamp, user, platform): + self.time = int(timestamp.timestamp() * 1000) # Amplitude time is milliseconds since epoch + self.user_id = f'{user.id:0>5}' # Amplitude user ID is a string type, min length is 5 which is weird + + platform_data = user.get_platform_data(platform) + self.device_id = platform_data['anonymous_id'] + if platform == 'ios': + self.idfa = platform_data['advertising_id'] + self.platform = 'iOS' + self.device_model = platform_data['model'] + self.os_version = platform_data['version'] + elif platform == 'android': + self.adid = platform_data['advertising_id'] + self.device_model = platform_data['model'] + self.os_version = platform_data['version'] + + def toJson(self): + return self.__repr__() + + def __repr__(self): + return json.dumps(self.__dict__) + +class AmplitudeIdentifyEvent(AmplitudeEvent): + def __init__(self, timestamp, user, platform): + super().__init__(timestamp, user, platform) + self.event_type = '$identify' + self.user_properties = user.traits + self.user_properties['name'] = user.name + self.user_properties['email'] = user.email + self.user_properties['age'] = user.age + self.user_properties['gender'] = user.gender + self.user_properties['persona'] = user.persona + self.user_properties['username'] = user.username + +class AmplitudeTrackEvent(AmplitudeEvent): + def __init__(self, name, timestamp, user, platform, properties): + super().__init__(timestamp, user, platform) + self.event_type = name + self.event_properties = properties + +class AmplitudeSender: + def __init__(self, config): + self.config = config # MUST BE: { 'api_key': } + self.endpoint = 'https://api.amplitude.com/2/httpapi' + + def send_batch(self, platform, events, debug=False): + batch_events = { + "api_key": self.config['api_key'], + "events": events + } + + events_str = json.dumps(batch_events, default=lambda x: x.__dict__) + #print(f'Batch length bytes: {len(events_str)}') + if debug: + parsed = json.loads(events_str) + print(f'{json.dumps(parsed, indent=4)}') + response = None + else: + response = requests.post(self.endpoint, + data=events_str) + #print(self.config_keys[platform]) + #print(json.dumps(batch_events, default=lambda x: x.__dict__)) + #print(f'Sent {len(batch_events["batch"])} events and got {response}') + return response diff --git a/generators/datagenerator/file.py b/generators/datagenerator/file.py new file mode 100644 index 000000000..9d8c28d7c --- /dev/null +++ b/generators/datagenerator/file.py @@ -0,0 +1,25 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 + +class FileEvent: + def __init__(self, name, timestamp, user, platform, properties): + self.event = name + self.timestamp = timestamp.isoformat() + self.user_id = user.id + self.anonymous_id = user.get_platform_data(platform)['anonymous_id'] + self.platform = platform + self.traits = '' + + if len(user.traits.items()) > 0: + for (k,v) in user.traits.items(): + self.traits += f',{v}' + + def str(self): + return self.__repr__() + + def __repr__(self): + output = f'{self.event},{self.timestamp},{self.user_id},{self.anonymous_id},{self.platform}' + if len(self.traits) > 0: + output += self.traits + output += f'\n' + return output \ No newline at end of file diff --git a/generators/datagenerator/funnel.py b/generators/datagenerator/funnel.py new file mode 100644 index 000000000..d34b48782 --- /dev/null +++ b/generators/datagenerator/funnel.py @@ -0,0 +1,68 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 + +import random +import numpy as np +import datetime +import inspect +from datagenerator.output import OutputFormatter +from collections.abc import Mapping, Iterable + +class Funnel: + def __init__(self, timestamp, funnel, user): + self.funnel = funnel + self.event_index = 0 + self.timestamp = timestamp + self.platform = self.funnel['platform'] + self.user = user + + if 'user_props' in self.funnel: + self.user.set_traits(self.funnel['user_props']) + self.identify = True + else: + self.identify = False + + if 'state' in self.funnel: + self.state = self.funnel['state'](self.user) # Passes the user to the state lambda + else: + self.state = None + + def __iter__(self): + return self + + def __next__(self): + success_percent = min(100, 50 + (self.event_index * 10)) / 100 + proceed = self.proceed(success_percent) + at_start = self.event_index == 0 + not_at_end = self.event_index < len(self.funnel['templates']) + # This is to make sure that you always get at least the first event in a funnel, + # rest will be stochastic + if (proceed and not_at_end) or at_start: + formatter = OutputFormatter( + self.timestamp, + self.user, + self.platform, + self.generate_props(self.event_index), + self.funnel['templates'][self.event_index][0]) + self.timestamp += datetime.timedelta(seconds=random.randint(30, 600)) + self.event_index += 1 + return formatter + else: + raise StopIteration + + def generate_props(self, index): + template = self.funnel['templates'][index] + props = {} + for (k,v) in template[1].items(): + if k == 'expand' and callable(v): + props = {**props, **v(self.state)} + elif callable(v): + props[k] = v(self.state) + elif isinstance(v, Iterable): + props[k] = random.choice(v) + else: + props[k] = v + return props + + def proceed(self, p): + return np.random.binomial(1, p) \ No newline at end of file diff --git a/generators/datagenerator/output.py b/generators/datagenerator/output.py new file mode 100644 index 000000000..fd038e8cf --- /dev/null +++ b/generators/datagenerator/output.py @@ -0,0 +1,85 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 + +from datagenerator.segment import SegmentIdentifyEvent, SegmentTrackEvent, SegmentSender +from datagenerator.amplitude import AmplitudeIdentifyEvent, AmplitudeTrackEvent, AmplitudeSender +from datagenerator.file import FileEvent + +# TODO: Add Personalize output file formatter +# TODO: Add Amplitude output formatter + +class OutputFormatter: + def __init__(self, timestamp, user, platform, properties, name = None): + self.event = name + self.timestamp = timestamp + self.user = user + self.properties = properties + self.platform = platform + + def amplitude_identify(self): + return AmplitudeIdentifyEvent(self.timestamp, self.user, self.platform) + + def amplitude_event(self): + return AmplitudeTrackEvent(self.event, self.timestamp, self.user, self.platform, self.properties) + + def segment_track(self): + return SegmentTrackEvent(self.event, self.timestamp, self.user, self.platform, self.properties) + + def segment_identify(self): + return SegmentIdentifyEvent(self.timestamp, self.user, self.platform) + + def file_event(self): + return FileEvent(self.event, self.timestamp, self.user, self.platform, self.properties) + +class OutputWriter: + def __init__(self, sessions): + self.sessions = sessions + + def to_file(self, file_name): + # Write to the specified file using the FileEvent output formatter + f = open(file_name, 'w') + for funnel in self.sessions: + for formatter in funnel: + event = formatter.file_event() + f.write(event.str()) + + def to_amplitude(self, config, debug=False): + sender = AmplitudeSender(config) + print(f'Send config is: {config}.') + count = 0 + for funnel in self.sessions: + batch =[] + count += 1 + for formatter in funnel: + if funnel.identify: + # Send an identify call if specified in the funnel + event = formatter.amplitude_identify() + batch.append(event) + event = formatter.amplitude_event() + batch.append(event) + if len(batch) > 0: + response = sender.send_batch(funnel.platform, batch, debug) + if response != None and response.status_code > 200: + print(f'Error sending to Amplitude: {response.text}') + print(f'Processed {count} funnels...') + + def to_segment(self, config_file, debug=False): + # Write to Segment, using the specified config file + sender = SegmentSender('segment_config.yaml') + print(f'Send config is: {sender.config_keys}') + count = 0 + for funnel in self.sessions: + batch = [] + count += 1 + for formatter in funnel: + if funnel.identify: + # Send an identify call if specified in the funnel + event = formatter.segment_identify() + batch.append(event) + event = formatter.segment_track() + batch.append(event) + if len(batch) > 0: + response = sender.send_batch(funnel.platform, batch, debug) + if response != None and response.status_code > 200: + print(f'Error sending to Segment: {response.text}') + print(f'Processed {count} funnels...') \ No newline at end of file diff --git a/generators/datagenerator/rdscatalog.py b/generators/datagenerator/rdscatalog.py new file mode 100644 index 000000000..c3c8d42a8 --- /dev/null +++ b/generators/datagenerator/rdscatalog.py @@ -0,0 +1,14 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 + +import yaml +from collections import UserList + +class RDSCatalog(UserList): + def __init__(self, file): + self.data = [] + f = open(file) + self.data = yaml.load(f, Loader=yaml.FullLoader) + + def subcategory_sample(self, categories): + return list(filter(lambda item: item['category'] in categories, self.data)) \ No newline at end of file diff --git a/generators/datagenerator/rdsuserstate.py b/generators/datagenerator/rdsuserstate.py new file mode 100644 index 000000000..63c8ce97e --- /dev/null +++ b/generators/datagenerator/rdsuserstate.py @@ -0,0 +1,75 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 + +import random +import uuid + +class RDSUserSelectionState: + def __init__(self, catalog, user): + if user.persona != '': # Added to support RDS personas from the catalog + self.search_results = catalog.subcategory_sample(user.persona.split('_')) + else: + self.search_results = random.sample(catalog, 10) + self.subsample = random.sample(self.search_results, 5) + self.cart = random.sample(self.subsample, 3) + self.cart_id = str(uuid.uuid4()) + self.search_terms = [] + for item in self.search_results: + self.search_terms.extend(item['name'].split(' ')) + + def search(self): + return self.search_results + + def user_search(self): + separator = ' ' + query = separator.join(random.sample(self.search_terms, 2)) + return query + + def recommendations(self): + return random.sample(self.subsample, 3) + + def cart_items(self): + return self.cart + + def num_results(self): + return len(self.search_results) + + def cart_value(self): + total = 0.0 + for item in self.cart: + total += item['price'] + return total + + def item(self): + return random.choice(self.cart) + + # These are specific to RDS event properties + def item_added_event_props(self): + item = self.item() + return { + 'productId': item['id'], + 'cartId': self.cart_id, + 'name': item['name'], + 'category': item['category'], + 'image': item['image'], + 'price': item['price'], + 'quantity': 1 + } + + def item_viewed_event_props(self): + item = self.item() + return { + 'productId': item['id'], + 'name': item['name'], + 'category': item['category'], + 'image': item['image'], + 'price': item['price'] + } + + def cart_viewed_event_props(self): + return { + 'cartId': self.cart_id, + 'cartSubTotal': self.cart_value(), + 'cartTotal': self.cart_value(), + 'cartQuantity': len(self.cart) + } \ No newline at end of file diff --git a/generators/datagenerator/segment.py b/generators/datagenerator/segment.py new file mode 100644 index 000000000..2f6c3c032 --- /dev/null +++ b/generators/datagenerator/segment.py @@ -0,0 +1,107 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 + +import datagenerator +import json +import requests + +# Segment event support +# This follows the Segment HTTP API spec, here: +# https://segment.com/docs/connections/sources/catalog/libraries/server/http-api/ +# +# These classes accept a user, platform, and general event properties and map them +# a Segment API compatible representation. This does not support implicit identify +# traits + +class SegmentEvent: + def __init__(self, timestamp, user, platform): + self.timestamp = timestamp.isoformat() + self.sentAt = timestamp.isoformat() + self.userId = user.id + + context = { + 'library': { + 'version': datagenerator.aws_datagenerator_version, + 'name': 'AWSEventGen' + } + } + + platform_data = user.get_platform_data(platform) + self.anonymousId = platform_data['anonymous_id'] + if platform == 'ios': + context['device'] = { + 'advertisingId': platform_data['advertising_id'], + 'manufacturer': 'tim_apple', + 'model': platform_data['model'], + 'version': platform_data['version'] + } + elif platform == 'android': + context['device'] = { + 'advertisingId': platform_data['advertising_id'], + 'manufacturer': 'google', + 'model': platform_data['model'], + 'version': platform_data['version'] + } + else: + context['userAgent'] = platform_data['user_agent'] + + self.context = context + + self.integrations = { + 'All': True + } + + def toJson(self): + return self.__repr__() + + def __repr__(self): + return json.dumps(self.__dict__) + +class SegmentIdentifyEvent(SegmentEvent): + def __init__(self, timestamp, user, platform): + super().__init__(timestamp, user, platform) + self.type = 'identify' + self.traits = user.traits + self.traits['name'] = user.name + self.traits['email'] = user.email + self.traits['age'] = user.age + self.traits['gender'] = user.gender + self.traits['persona'] = user.persona + self.traits['username'] = user.username + +class SegmentTrackEvent(SegmentEvent): + def __init__(self, name, timestamp, user, platform, properties): + super().__init__(timestamp, user, platform) + self.event = name + self.type = 'track' + self.properties = properties + +class SegmentSender: + def __init__(self, config): + self.config_keys = config # MUST BE: { 'ios': , 'android': , 'web': } + self.endpoint = 'https://api.segment.io/v1/batch' + + def send_batch(self, platform, events, debug=False): + batch_events = { + "batch": events + } + + key = self.config_keys[platform] + if key != None: + events_str = json.dumps(batch_events, default=lambda x: x.__dict__) + #print(f'Batch length bytes: {len(events_str)}') + if debug: + parsed = json.loads(events_str) + print(f'{json.dumps(parsed, indent=4)}') + response = None + else: + response = requests.post(self.endpoint, + data=events_str, + auth=(self.config_keys[platform], '')) + #print(self.config_keys[platform]) + #print(json.dumps(batch_events, default=lambda x: x.__dict__)) + #print(f'Sent {len(batch_events["batch"])} events and got {response}') + return response + else: + return None + diff --git a/generators/datagenerator/sessions.py b/generators/datagenerator/sessions.py new file mode 100644 index 000000000..1cab7e516 --- /dev/null +++ b/generators/datagenerator/sessions.py @@ -0,0 +1,34 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 + +import datetime +from collections import UserList +from datagenerator.funnel import Funnel +import random +import numpy as np + +class Sessions(UserList): + def __init__(self, from_datetime, to_datetime, event_templates, num_sessions, user_pool): + # defines % users for each 24 hour time slot, starting at midnight + self.percent_users = (1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 19, 20, 20, 9, 5, 4, 3, 1, 1, 1, 1, 1) + self.event_templates = event_templates + self.num_sessions = num_sessions + # Parse out the start date and turn it into a datetime - YYYY-mm-dd format + self.from_datetime = from_datetime + self.to_datetime = to_datetime + self.data = [] + + for hourly_users in self.user_time_slots(): + for i in range(hourly_users[1]): + active_user = np.random.binomial(1, .5) + user = user_pool.user(active_user) + # Pick a random funnel - note that the same user can repeat the same funnel several times potentially + funnel = random.choice(self.event_templates) + self.data.append(Funnel(hourly_users[0], funnel, user)) + + # Generates a series of datetime stamps for events (default every minute from start) + def user_time_slots(self): + curr = self.from_datetime + while curr < self.to_datetime: + yield (curr, int(self.num_sessions * (self.percent_users[curr.hour] / 100))) + curr += datetime.timedelta(hours=1) \ No newline at end of file diff --git a/generators/datagenerator/users.py b/generators/datagenerator/users.py new file mode 100644 index 000000000..a1f3981b4 --- /dev/null +++ b/generators/datagenerator/users.py @@ -0,0 +1,184 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 + +import random +import datetime +import uuid +import json +import numpy as np +import pprint +import gzip +import codecs +import bisect +from faker import Faker +from faker.providers import internet +from faker.providers import user_agent +from faker.providers import profile +from scipy.stats import truncnorm + +# Setup Faker +fake = Faker() +fake.add_provider(internet) +fake.add_provider(user_agent) +fake.add_provider(profile) + +# Normally distribute ages between 18 and 100 with a mean age of 32. +age_min = 18 +age_max = 100 +age_mean = 32 +age_sd = 15 + +age_dist = truncnorm((age_min - age_mean) / age_sd, (age_max - age_mean) / age_sd, loc=age_mean, scale=age_sd) + +# Persona combinations ordered from strongest affinity to latent interest. +personas = [ + 'apparel_housewares_accessories', 'housewares_apparel_electronics', + 'footwear_outdoors_apparel', 'outdoors_footwear_housewares', + 'electronics_beauty_outdoors', 'beauty_electronics_accessories', + 'jewelry_accessories_beauty', 'accessories_jewelry_apparel' +] + +class UserPool: + def __init__(self): + self.users = [] + self.active = [] + self.last_id = 0 + self.file = '' + + def size(self): + return len(self.users) + len(self.active) + + def active_users(self): + return len(self.active) + + def grow_pool(self, num_users): + for i in range(num_users): + user = User() + self.last_id += 1 # Avoids user ID collisions + user.id = str(self.last_id) + self.users.append(User()) + + def user(self, select_active=False): + if len(self.users) == 0: + self.grow_pool(1000) + self.save(self.file) # Cache the whole pool back to the file + if select_active and len(self.active) > 0: + user = random.choice(self.active) + else: + user = self.users.pop(random.randrange(len(self.users))) + self.active.append(user) + return user + + def save(self, file): + all_users = [] + all_users.extend(self.users) + all_users.extend(self.active) + json_data = json.dumps(all_users, default=lambda x: x.__dict__) + f = gzip.open(file, 'wt', encoding='utf-8') + f.write(json_data) + f.close() + + @classmethod + def from_file(cls, filename): + user_pool = cls() + user_pool.file = filename + with gzip.open(filename, 'rt', encoding='utf-8') as f: + data = json.load(f) + f.close() + user_ids = [] + for saved_user in data: + user = User.from_file(saved_user) + bisect.insort(user_ids, int(user.id)) + user_pool.last_id = user_ids[len(user_ids) - 1] + user_pool.users.append(user) + return user_pool + + @classmethod + def new_file(cls, filename, num_users): + user_pool = cls() + user_pool.file = filename + user_pool.grow_pool(num_users) + user_pool.save(filename) + return user_pool + +class User: + def __init__(self): + self.id = str(random.randint(1000000000, 99999999999)) + self.gender = random.choice(['M', 'F']) + if self.gender == 'F': + self.first_name = fake.first_name_female() + self.last_name = fake.last_name_female() + else: + self.first_name = fake.first_name_male() + self.last_name = fake.last_name_male() + + address_state = fake.state_abbr(include_territories=True) + email_first = self.first_name.replace(' ', '').lower() + email_last = self.last_name.replace(' ', '').lower() + self.email = f'{email_first}.{email_last}@example.com' + self.age = int(age_dist.rvs()) + self.name = f'{self.first_name} {self.last_name}' + self.username = f'user{self.id}' + # These are hard-coded from the AWS samples Retail Demo Store workshop + self.persona = random.choice(personas) + self.traits = {} + + ios_token = fake.ios_platform_token() + ios_identifiers = ios_token.split(' ') + android_token = fake.android_platform_token() + android_identifiers = android_token.split(' ') + + self.platforms = { + "ios": { + "anonymous_id": str(uuid.uuid4()), + "advertising_id": str(uuid.uuid4()), + "user_agent": ios_token, + "model": ios_identifiers[0], + "version": ios_identifiers[4] + }, + "android": { + "anonymous_id": str(uuid.uuid4()), + "advertising_id": str(uuid.uuid4()), + "user_agent": android_token, + "version": android_identifiers[1] + }, + "web": { + "anonymous_id": str(uuid.uuid4()), + "user_agent": fake.user_agent() + } + } + + self.addresses = [ + { + 'first_name': self.first_name, + 'last_name': self.last_name, + 'address1': fake.street_address(), + 'address2': '', + 'country': 'US', + 'city': fake.city(), + 'state': address_state, + 'zipcode': fake.postcode_in_state(state_abbr=address_state), + 'default': True + } + ] + + def set_traits(self, traits): + if traits != None: + for (k,v) in traits.items(): + self.traits[k] = random.choice(v) + + def get_platform_data(self, platform): + return self.platforms[platform] + + def toJson(self): + return self.__repr__() + + def __repr__(self): + return json.dumps(self.__dict__) + + @classmethod + def from_file(cls, user_dict): + user = cls() + for (k,v) in user_dict.items(): + setattr(user,k, v) # Danger, Will Robinson + return user diff --git a/generators/generate_users_json.py b/generators/generate_users_json.py index 813d77484..bb509a3ba 100644 --- a/generators/generate_users_json.py +++ b/generators/generate_users_json.py @@ -1,88 +1,23 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: MIT-0 -""" Generates list of random users using python's Faker +""" +Generates list of random users using the datagenerator library included in +this directory. The random users are written to a gzipped JSON file which, when copied into place, will be loaded by the Users microservice during initialization. This script only needs to be run once to produce a random users data -file that is bundled with all Retail Demo Store deployments. Therefore there -are no deployment or run-time dependencies on this script. +file that is bundled with all Retail Demo Store deployments. """ -import json -import csv -import uuid -import random -import gzip -from scipy.stats import truncnorm -from faker import Faker +from datagenerator.users import UserPool -fake = Faker() -num_users = 5000 -users = [] - -# Normally distribute ages between 18 and 100 with a mean age of 32. -age_min = 18 -age_max = 100 -age_mean = 32 -age_sd = 15 - -age_dist = truncnorm((age_min - age_mean) / age_sd, (age_max - age_mean) / age_sd, loc=age_mean, scale=age_sd) - -# Persona combinations ordered from strongest affinity to latent interest. -personas = [ - 'apparel_housewares_accessories', 'housewares_apparel_electronics', - 'footwear_outdoors_apparel', 'outdoors_footwear_housewares', - 'electronics_beauty_outdoors', 'beauty_electronics_accessories', - 'jewelry_accessories_beauty', 'accessories_jewelry_apparel' -] +num_users = 6000 print('Generating {} random users...'.format(num_users)) -for x in range(0, num_users): - - gender = random.choice(['M', 'F']) - - if gender == 'F': - first_name = fake.first_name_female() - last_name = fake.last_name_female() - else: - first_name = fake.first_name_male() - last_name = fake.last_name_male() - - address_state = fake.state_abbr(include_territories=True) - - person = { - 'id': str(x), - 'first_name': first_name, - 'last_name': last_name, - 'gender': gender, - 'email': '{}.{}@example.com'.format(first_name.replace(' ', '').lower(), last_name.replace(' ', '').lower()), - 'username': 'user{}'.format(x), - 'age': int(age_dist.rvs()), - 'persona': random.choice(personas), - 'addresses': [ - { - 'first_name': first_name, - 'last_name': last_name, - 'address1': fake.street_address(), - 'address2': '', - 'country': 'US', - 'city': fake.city(), - 'state': address_state, - 'zipcode': fake.postcode_in_state(state_abbr=address_state), - 'default': True - } - ], - } - - users.append(person) - -# Write users array as a compressed JSON file. -print('Writing users to compressed JSON file...') -with gzip.GzipFile('users.json.gz', 'w') as fout: - fout.write(json.dumps(users, indent=2).encode('utf-8')) +pool = UserPool.new_file('users.json.gz', num_users) print('Done') \ No newline at end of file diff --git a/generators/requirements.txt b/generators/requirements.txt index 2fbb6d104..9640e381d 100644 --- a/generators/requirements.txt +++ b/generators/requirements.txt @@ -1,2 +1,16 @@ -Faker==4.0.2 -scipy==1.3.1 \ No newline at end of file +certifi==2020.4.5.1 +chardet==3.0.4 +DateTime==4.3 +Faker==4.1.0 +idna==2.9 +numpy==1.18.5 +pprint==0.1 +python-dateutil==2.8.1 +pytz==2020.1 +PyYAML==5.3.1 +requests==2.23.0 +scipy==1.4.1 +six==1.15.0 +text-unidecode==1.3 +urllib3==1.25.9 +zope.interface==5.1.0 diff --git a/src/users/src/users-service/data/users.json.gz b/src/users/src/users-service/data/users.json.gz index e6b8d8817..adb878677 100644 Binary files a/src/users/src/users-service/data/users.json.gz and b/src/users/src/users-service/data/users.json.gz differ diff --git a/stage.sh b/stage.sh index 3b357691a..34439b198 100755 --- a/stage.sh +++ b/stage.sh @@ -41,8 +41,16 @@ echo " + Uploading CloudFormation Templates" aws s3 cp aws/cloudformation-templates/ s3://${BUCKET}/${S3PATH}cloudformation-templates --recursive $S3PUBLIC echo " For CloudFormation : https://${BUCKET_DOMAIN}/${BUCKET}/${S3PATH}cloudformation-templates/template.yaml" -echo " + Packaging Notebooks" +echo " + Copying Notebook Dependencies" [ -e "retaildemostore-notebooks.zip" ] && rm retaildemostore-notebooks.zip +rsync -av --progress ./generators/datagenerator ./workshop --exclude __pycache__ +cp ./generators/requirements.txt ./workshop + +[ ! -d "./workshop/data" ] && mkdir ./workshop/data +cp ./src/products/src/products-service/data/products.yaml ./workshop/data +cp ./src/users/src/users-service/data/users.json.gz ./workshop/data + +echo " + Packaging Notebooks" zip -qr retaildemostore-notebooks.zip ./workshop/ -x "*.DS_Store" "*.ipynb_checkpoints*" "*.csv" echo " + Uploading Notebooks" diff --git a/workshop/3-Experimentation/3.5-Amplitude-Performance-Metrics.ipynb b/workshop/3-Experimentation/3.5-Amplitude-Performance-Metrics.ipynb new file mode 100644 index 000000000..c45ba8ccc --- /dev/null +++ b/workshop/3-Experimentation/3.5-Amplitude-Performance-Metrics.ipynb @@ -0,0 +1,496 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Measuring Personalization Performance Using Amplitude\n", + "In this exercise you will explore the conversion performance of your Personalize deployment in Retail Demo Store using [Amplitude's](https://amplitude.com/) real-time analytics platform.\n", + "\n", + "*Recommended Time: 30 minutes*\n", + "\n", + "## Prerequisites\n", + "\n", + "This module uses [Amplitude](https://amplitude.com/) to measure the performance of the personalization features in the Retail Demo Store. It is assumed that you have either completed the Personalization workshop or those resources have been pre-provisioned in your AWS environment. If you are unsure and attending an AWS managed event such as a workshop, check with your event lead.\n", + "\n", + "This project also assumes that Amplitude has been configured during the deployment of the Retail Demo Store in your AWS account or managed event account.\n", + "\n", + "In order to deploy Amplitude, you will need access to an Amplitude workspace, and an Amplitude API key that allows you to send data to Amplitude. We recommend setting up a separate Amplitude project for the workshop, so that you can delete the demo data that will be passed to Amplitude as part of the workshop when you are finished.\n", + "\n", + "## Overview\n", + "\n", + "Deploying personalizaiton can be challenging in two ways:\n", + "\n", + "1. Deciding where to deploy personalized experiences is often done using best guesses\n", + "2. Measuring the conversion outcomes of personalized experiences once they are deployed can be difficult without proper tooling\n", + "\n", + "Amplitude addresses these issues by providing a comprehensive real-time measurement platform that allows you to track user behavior in the form of real-time events before and after the deployment of Personalize. \n", + "\n", + "In this workshop, you will set up tracking for Amplitude events, analyze user behavior prior to peronalization being deployed, and then measure the effects of personalization on user behavior after Personalize is deployed in the Retail Demo Store.\n", + "\n", + "In the following steps, you will feed data about user behavior into Amplitude. The dataset will include 60 days worth of simulated data going back from today. The first 30 days will represent the time period before personalization is deployed, and the last thirty days will include user behavior data after personalization is deployed in the Retail Demo Store site.\n", + "\n", + "## Amplitude Deployment Architecture\n", + "\n", + "![Amplitude Deployment Architecture](./images/amplitude/workshop-architecture.png)\n", + "\n", + "## Set Up Amplitude\n", + "\n", + "Amplitude works by collecting real-time data from user behavior in the Retail Demo Store website. The Amplitude tracking code is included in the workshop for you, so user interactions with the application will be collected in Amplitude if you included your Amplitude API key during the deployment of the Retail Demo Store environment.\n", + "\n", + "Because the Retail Demo Store is developed using the Vue framework, we can load Amplitude when the user loads a page by including the Amplitude tracking script using an import statement in `../src/web-ui/src/analytics/AnalyticsHandler.js` (this file can be found in the Github repo for this project):\n", + "\n", + "```\n", + "/* \n", + " * Centralized handling of all analytics calls for Pinpoint, Personalize \n", + " * (event tracker), and partner integrations.\n", + " */\n", + "import Vue from 'vue';\n", + "import { Analytics as AmplifyAnalytics } from '@aws-amplify/analytics';\n", + "import Amplitude from 'amplitude-js'\n", + "```\n", + "\n", + "Once the tracking script is loaded into a page, it can be used to fire events in response to user actions. As an example, when a user clicks on a product on one of the Retail Demo Store pages, a ProductViewed event will be sent to Amplitude, using this code:\n", + "\n", + "```\n", + "if (this.amplitudeEnabled()) {\n", + " // Amplitude event\n", + " var eventProperties = {\n", + " productId: product.id,\n", + " name: product.name,\n", + " category: product.category,\n", + " image: product.image,\n", + " feature: feature,\n", + " experimentCorrelationId: experimentCorrelationId,\n", + " price: +product.price.toFixed(2)\n", + " };\n", + " Amplitude.getInstance().logEvent('ProductViewed', eventProperties);\n", + "}\n", + "```\n", + "\n", + "These events are the same as events that are used to train the Personalize models used to offer users Personalize recommendations. This is important since you will want the reports in Amplitude's analytics platform to reflect the same information that is being used to create Personalize models. This will give you the most accurate way to measure user behavior before Personalize is deployed, and a consistent way to measure user behavior after Personalize is deployed in the Retail Demo Store site.\n", + "\n", + "Retail Demo Store sends the following events to Personalize and Amplitude:\n", + "\n", + "|||||\n", + "|--- |--- |--- |--- |\n", + "|Event Name|Event Description|Personalize Training|Amplitude|\n", + "|ProductAdded|Every time a user adds a product to their cart; tracks the ID of the product that was added|X|X|\n", + "|ProductRemoved|Every time a user removes a product from their cart||X|\n", + "|ProductQuantityUpdated|Every time a user adds an additional unit of a product|X|X|\n", + "|ProductViewed|Every time a user views a product detail page|X|X|\n", + "|CartViewed|Every time a user views the contents of their shopping cart|X|X|\n", + "|CheckoutStarted|Every time a user begins the checkout process|X|X|\n", + "|OrderCompleted|Every time a user completes a purchase successfully|X|X|\n", + "\n", + "## Measuring Conversion Before Personalization is Deployed\n", + "\n", + "Because Amplitude reports rely on real user data, you will be using a data simulator to create enough events for you to be able to run a useful report in Amplitude. The data simulator included in the Retail Demo Store will send the events shown above as though they were being generated by real users of the Retail Demo Store. This will simulate common browsing behaviors at a sufficent volume to allow you to run a report that looks like one that might be used in your own production applications.\n", + "\n", + "The first simulated set of data will represent 30 days of user behavior in the Retail Demo Store prior to the deployment of the three personalization features you deployed in the first workshop exercise.\n", + "\n", + "Before you run the code, you need to get your Amplitude key from SSM. This will allow you to send simulated data directly to Amplitude.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import boto3\n", + "\n", + "ssm = boto3.client('ssm')\n", + "\n", + "response = ssm.get_parameter(\n", + " Name='retaildemostore-amplitude-api-key'\n", + ")\n", + "\n", + "amplitude_config = {\n", + " 'api_key': response['Parameter']['Value'] # Do Not Change\n", + "}\n", + "\n", + "print(amplitude_config)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If the cell above returned a valid Amplitude API key, you can run the following cell to populate your Amplitude project with data.\n", + "\n", + "If you did not get a valid API key, it is likely you did not configure it in your deployment of Retail Demo Store. Please go back and re-deploy Retail Demo Store with a valid Amplitude API key for the Amplitude project you plan to use for this workshop." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Demo Data Simulator\n", + "\n", + "The code in the following cell implements a user simulator that mimics the behavior of real users. The `usage_funnels_pre_personalize` array specifies a series of funnels that the simulator will use to generate events that are sent to Amplitude. \n", + "\n", + "The simulator uses the user profiles that are provided with the Retail Demo Store workshop, so that the user profiles shown in Amplitude reflect the same profiles you will see in the Retail Demo Store Users service. Once a user profile is selected (at random), it will attempt to run the user through a selected funnel. Not all users will complete each funnel, and some users may end up running through multiple funnels, depending on a weighted randomization function in the simulator. This is done to provide realistic user behavior in the event patterns shown in Amplitude. \n", + "\n", + "The simulator will also create events based on a time of day algorithm, where a majority of users will end up being more active during business hours, and fewer users will be active during after hours. User profiles are also mapped to particular product affinities in the Retail Demo Store catalog, so that it is possible to create user behaviors that are specific to certain kinds of product categories.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import sys\n", + "\n", + "sys.path.insert(0, os.path.abspath('../'))\n", + "!pip install -r ../requirements.txt\n", + "\n", + "import datetime\n", + "from datagenerator.rdscatalog import RDSCatalog\n", + "from datagenerator.rdsuserstate import RDSUserSelectionState\n", + "from datagenerator.users import UserPool\n", + "from datagenerator.sessions import Sessions\n", + "from datagenerator.output import OutputWriter\n", + "\n", + "search_props_template = {\n", + " 'query': RDSUserSelectionState.user_search,\n", + " 'resultCount': RDSUserSelectionState.num_results,\n", + " 'reranked': False\n", + "}\n", + "\n", + "search_props_feature_template = {\n", + " 'query': RDSUserSelectionState.user_search,\n", + " 'resultCount': RDSUserSelectionState.num_results,\n", + " 'reranked': True\n", + "}\n", + "\n", + "prod_clicked_template = {\n", + " 'expand': RDSUserSelectionState.item_viewed_event_props\n", + "}\n", + "\n", + "prod_clicked_feature_template = {\n", + " 'expand': RDSUserSelectionState.item_viewed_event_props,\n", + " 'feature': ['home_product_recs', 'product_detail_related']\n", + "}\n", + "\n", + "prod_added_template = {\n", + " 'expand': RDSUserSelectionState.item_added_event_props,\n", + "}\n", + "\n", + "checkout_start_template = {\n", + " 'expand': RDSUserSelectionState.cart_viewed_event_props,\n", + "}\n", + "\n", + "order_completed_template = {\n", + " 'expand': RDSUserSelectionState.cart_viewed_event_props\n", + "}\n", + "\n", + "catalog = RDSCatalog('../data/products.yaml')\n", + "\n", + "usage_funnels_pre_personalize = [\n", + " {\n", + " 'platform': 'web',\n", + " 'state': lambda user: RDSUserSelectionState(catalog, user),\n", + " 'templates': [\n", + " ('ProductSearched', search_props_template),\n", + " ('ProductViewed', prod_clicked_template),\n", + " ('ProductAdded', prod_added_template),\n", + " ('CartViewed', prod_clicked_template),\n", + " ('CheckoutStarted', checkout_start_template),\n", + " ('OrderCompleted', order_completed_template)\n", + " ]\n", + " },\n", + " {\n", + " 'platform': 'web',\n", + " 'state': lambda user: RDSUserSelectionState(catalog, user),\n", + " 'templates': [\n", + " ('ProductViewed', prod_clicked_template),\n", + " ('ProductAdded', prod_added_template),\n", + " ('CartViewed', prod_clicked_template),\n", + " ('CheckoutStarted', checkout_start_template),\n", + " ('OrderCompleted', order_completed_template)\n", + " ]\n", + " },\n", + " {\n", + " 'platform': 'web',\n", + " 'state': lambda user: RDSUserSelectionState(catalog, user),\n", + " 'templates': [\n", + " ('ProductViewed', prod_clicked_template)\n", + " ]\n", + " },\n", + " {\n", + " 'platform': 'web',\n", + " 'state': lambda user: RDSUserSelectionState(catalog, user),\n", + " 'templates': [\n", + " ('ProductsSearched', prod_clicked_template)\n", + " ]\n", + " }\n", + "]\n", + "\n", + "date_time_now = datetime.datetime.now()\n", + "\n", + "pool = UserPool.from_file('../data/users.json.gz') # Load the known pool of users from the users file\n", + "print(f'Loaded {pool.size()} users...')\n", + "# Sends events to simulate application prior to personalization\n", + "sessions = Sessions(date_time_now - datetime.timedelta(days=60), date_time_now - datetime.timedelta(days=30), usage_funnels_pre_personalize, 100, pool)\n", + "writer = OutputWriter(sessions)\n", + "writer.to_amplitude(amplitude_config)\n", + "print(f'Events Send Completed')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create Amplitude Conversion Reports\n", + "\n", + "Once the event generator has completed, log in to the [Amplitude console](https://analytics.amplitude.com).\n", + "\n", + "In the console, you will use Amplitude's Funnel Analysis report to generate a report that enables you to see the conversion rate of the Retail Demo Store main purchase funnel. Amplitude allows you to build a funnel for any user flow in your applications, but here we will focus on events leading to a purchase through the Retail Demo Store main purchase funnel. You will build a report that tracks all user sessions that perform the following actions in this order:\n", + "\n", + "ProductViewed >\n", + "ProductAdded >\n", + "CheckoutStarted >\n", + "OrderCompleted\n", + "\n", + "Note that users might perform other steps in the middle of this flow, such as viewing other products, adding and removing items from their cart, etc. but the Funnel report will ignore those events for the purpose of this report, since we are looking to see only conversions for users that have viewed a particular product and then purchased it through the minimum checkout flow steps for the Retail Demo Store.\n", + "\n", + "![](images/amplitude/conversion-funnel-pre-setup.png)\n", + "\n", + "To configure a Funnel report, select the New button in the left hand side of the screen, and then Report. Select Funnel Analysis for your report type at the top of the screen. In the events section below that, you will see a screen that looks like this:\n", + "\n", + "![](images/amplitude/conversion-funnel-pre-settings.png)\n", + "\n", + "Select the ProductViewed, ProductAdded, CheckoutStarted, and OrderCompleted events in that order. Once that is complete, select the data dropdown on the lower right hand side of the screen, and select the Between tab. Then input a start date 60 days before today as a start and 30 days before today as an end date. This report will show conversion data for the 30 days *prior* to personalization being deployed in the Retail Demo Store. \n", + "\n", + "![](images/amplitude/conversion-funnel-pre-dates.png)\n", + "\n", + "Your funnel report will look something like this, though the exact conversion numbers and number of events will vary slightly from this screen shot:\n", + "\n", + "![](images/amplitude/conversion-funnel-pre-pers.png)\n", + "\n", + "Click on the lower part of the OrderCompleted bar graph. Note that the purchase conversion rate for the main funnel in the Retail Demo Store is 23.8%. Not bad, but would we see an improvement in conversion if users were to receive personalized recommendations while they are interacting with the Retail Demo Store?\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Measuring the Conversion Effects of Personalized Product Recommendations\n", + "\n", + "In the first workshop, you deployed the following Personalization features in the Retail Demo Store:\n", + "\n", + "1. Personalized product recommendations\n", + "2. Similar product recommendations\n", + "3. Personalized ranking of products on category detail pages and personalized ranking of search results\n", + "\n", + "In order to measure how users convert after interacting with these features, we need to add a parameter to the event tracking sent to Amplitude, so that our funnel report can differentiate between product views and purchases that are *not* influenced via personalization features and product views and purchases that are a result of user interaction with the newly-added personalization features.\n", + "\n", + "In the `AnalyticsHandler.js` file, you will see code that adds a parameter to the ProductViewed event:\n", + "\n", + "```\n", + "if (this.amplitudeEnabled()) {\n", + " // Amplitude event\n", + " var eventProperties = {\n", + " productId: product.id,\n", + " name: product.name,\n", + " category: product.category,\n", + " image: product.image,\n", + " feature: feature,\n", + " experimentCorrelationId: experimentCorrelationId,\n", + " price: +product.price.toFixed(2)\n", + " };\n", + " Amplitude.getInstance().logEvent('ProductViewed', eventProperties);\n", + "}\n", + "```\n", + "\n", + "The `feature` property will contain the name of the personalization feature that the user clicked on. This will be used for personalized product recommendations and for similar product recommendations. \n", + "\n", + "This property will allow us to create a funnel report in Amplitude that shows the conversion rate of products clicked by users as a result of viewing a Personalization feature.\n", + "\n", + "Run the following cell to generate data that will enable you to build a report that uses the `feature` property to analyze conversions after the personalization features have been deployed.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "usage_funnels_post_personalize_features = [\n", + " {\n", + " 'platform': 'web',\n", + " 'state': lambda user: RDSUserSelectionState(catalog, user),\n", + " 'templates': [\n", + " ('ProductSearched', search_props_feature_template),\n", + " ('ProductViewed', prod_clicked_template),\n", + " ('ProductAdded', prod_added_template),\n", + " ('CartViewed', prod_clicked_template),\n", + " ('CheckoutStarted', checkout_start_template),\n", + " ('OrderCompleted', order_completed_template)\n", + " ]\n", + " },\n", + " {\n", + " 'platform': 'web',\n", + " 'state': lambda user: RDSUserSelectionState(catalog, user),\n", + " 'templates': [\n", + " ('ProductViewed', prod_clicked_feature_template),\n", + " ('ProductAdded', prod_added_template),\n", + " ('CartViewed', prod_clicked_template),\n", + " ('CheckoutStarted', checkout_start_template),\n", + " ('OrderCompleted', order_completed_template)\n", + " ]\n", + " },\n", + " {\n", + " 'platform': 'web',\n", + " 'state': lambda user: RDSUserSelectionState(catalog, user),\n", + " 'templates': [\n", + " ('ProductViewed', prod_clicked_template)\n", + " ]\n", + " },\n", + " {\n", + " 'platform': 'web',\n", + " 'state': lambda user: RDSUserSelectionState(catalog, user),\n", + " 'templates': [\n", + " ('ProductViewed', prod_clicked_template),\n", + " ('ProductAdded', prod_added_template),\n", + " ('CartViewed', prod_clicked_template),\n", + " ('CheckoutStarted', checkout_start_template),\n", + " ('OrderCompleted', order_completed_template)\n", + " ]\n", + " }\n", + "]\n", + "\n", + "# Sends events to simulate application after personalization features are added\n", + "sessions = Sessions(date_time_now - datetime.timedelta(days=30), date_time_now, usage_funnels_post_personalize_features, 100, pool)\n", + "writer = OutputWriter(sessions)\n", + "writer.to_amplitude(amplitude_config)\n", + "print(f'Events Send Completed')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create Amplitude Personalization Conversion Reports \n", + "\n", + "Once the event generator has completed, go back to the Amplitude console.\n", + "\n", + "In the console, you will once again use Amplitude's Funnel Analysis report to generate a report that enables you to see the conversion rate of the Retail Demo Store main purchase funnel. This time after the Personalize features have been deployed. We will still track the normal conversion funnel as before, but now we are going to drill down into ProductViewed events that were a result of the first two personalizaiton features we deployed earlier.\n", + "\n", + "ProductViewed >\n", + "ProductAdded >\n", + "CheckoutStarted >\n", + "OrderCompleted\n", + "\n", + "Instead of looking at all user paths through this funnel, we will look individually at the number of conversions that start with an interaction with a personalization feature. Under the ProductViewed event, select `where` and then the `feature` property. This will show a dropdown window that allows you to specify which values for the feature will be selected for the report. In this case, we will look for events that are tied to 'product_detail_related' and 'home_product_recs'. These values correspond to the Related Products feature, and the Product Recommendations feature for users that land on the home page.\n", + "\n", + "![](./images/amplitude/product-viewed-feature-select.png)\n", + "\n", + "This flow will now show conversions only for events that resulted in a purchase as a result of the user having initially looked at a product that was recommended on the home page (user personalization) or as a result of a related product recommendation (similar products personalization).\n", + "\n", + "![](images/amplitude/conversion-funnel-post-pers.png)\n", + "\n", + "This funnel shows a combined conversion rate of 30.6%. This means that for users that have clicked on products that are recommended by these two features, there is a 6.8% increase in conversion (over the 23.8% conversion rate for for all users that view recommended or similar products over users that find products organically.\n", + "\n", + "Speaking of organic searches, let's look at how personalized search results ranking performed against regular searches." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create Amplitude Search Personalization Conversion Reports\n", + "\n", + "The Retail Demo Store allows users to perform a text search for products. In the earlier part of the workshop, you deployed Personalize to re-sort product searches to show users products that might be more relevant based on their browsing history.\n", + "\n", + "When the user performs a search in the Retail Demo Store, a ProductSearched event is sent:\n", + "\n", + "```\n", + "if (this.amplitudeEnabled()) {\n", + " // Amplitude event\n", + " var eventProperties = {\n", + " query: query,\n", + " reranked: (user ? 'true' : 'false'),\n", + " resultCount: numResults\n", + " };\n", + " Amplitude.getInstance().logEvent('ProductSearched', eventProperties);\n", + "}\n", + "```\n", + "\n", + "If the search results are re-ranked by Personalize, the `reranked` property will be set to True in the event. This means that we can compare user funnels from before and after Personalize is deployed.\n", + "\n", + "For this funnel report, you will create an Amplitude funnel report that looks at:\n", + "\n", + "ProductSearched >\n", + "ProductAdded >\n", + "CheckoutStarted >\n", + "OrderCompleted\n", + "\n", + "First, let's look at search conversion before Personalize is deployed. Set up a funnel report, with the events shown above.\n", + "\n", + "![](images/amplitude/search-funnel-pre-setup.png)\n", + "\n", + "Then select a time range that between 60 and 30 days before today (this is the time period prior to personalization being deployed in your dataset).\n", + "\n", + "![](images/amplitude/search-funnel-pre-dates.png)\n", + "\n", + "You should see a report that looks like this.\n", + "\n", + "![](images/amplitude/search-funnel-pre-pers.png)\n", + "\n", + "In this case, there is a 43% conversion rate for organic searches without personalized ranking.\n", + "\n", + "Now, let's compare these results to search conversion after Personalize is deployed to re-rank search results. In your funnel report, select the `where` option for the `ProductSearched` event, and then select the `reranked` property. Select the `True` option.\n", + "\n", + "![](images/amplitude/search-funnel-post-setup.png)\n", + "\n", + "Then, select a time frame for the last 30 days, since this how long your dataset has personalization event data.\n", + "\n", + "![](images/amplitude/search-funnel-post-dates.png)\n", + "\n", + "Your report should now show conversions only for personalized events.\n", + "\n", + "![](images/amplitude/search-funnel-post.png)\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Workshop Complete\n", + "\n", + "Hopefully this introduction to user behavior monitoring using Amplitude showed you how you can deploy a similar solution along with Amazon Personalize. \n", + "\n", + "### Cleanup\n", + "\n", + "Please remember to delete the Amplitude data that was sent to your Amplitude project when you are finished with it.\n" + ] + } + ], + "metadata": { + "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.8.3-final" + }, + "orig_nbformat": 2, + "kernelspec": { + "name": "python38364bit215f3dce9b6a460f8a626fcbd8bfa8d4", + "display_name": "Python 3.8.3 64-bit" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} \ No newline at end of file diff --git a/workshop/3-Experimentation/images/amplitude/conversion-funnel-post-pers.png b/workshop/3-Experimentation/images/amplitude/conversion-funnel-post-pers.png new file mode 100644 index 000000000..677568f70 Binary files /dev/null and b/workshop/3-Experimentation/images/amplitude/conversion-funnel-post-pers.png differ diff --git a/workshop/3-Experimentation/images/amplitude/conversion-funnel-pre-dates.png b/workshop/3-Experimentation/images/amplitude/conversion-funnel-pre-dates.png new file mode 100644 index 000000000..b0d74b4b7 Binary files /dev/null and b/workshop/3-Experimentation/images/amplitude/conversion-funnel-pre-dates.png differ diff --git a/workshop/3-Experimentation/images/amplitude/conversion-funnel-pre-pers.png b/workshop/3-Experimentation/images/amplitude/conversion-funnel-pre-pers.png new file mode 100644 index 000000000..4dfea17dc Binary files /dev/null and b/workshop/3-Experimentation/images/amplitude/conversion-funnel-pre-pers.png differ diff --git a/workshop/3-Experimentation/images/amplitude/conversion-funnel-pre-settings.png b/workshop/3-Experimentation/images/amplitude/conversion-funnel-pre-settings.png new file mode 100644 index 000000000..c25eeccbe Binary files /dev/null and b/workshop/3-Experimentation/images/amplitude/conversion-funnel-pre-settings.png differ diff --git a/workshop/3-Experimentation/images/amplitude/conversion-funnel-pre-setup.png b/workshop/3-Experimentation/images/amplitude/conversion-funnel-pre-setup.png new file mode 100644 index 000000000..0d0e1368a Binary files /dev/null and b/workshop/3-Experimentation/images/amplitude/conversion-funnel-pre-setup.png differ diff --git a/workshop/3-Experimentation/images/amplitude/product-viewed-feature-select.png b/workshop/3-Experimentation/images/amplitude/product-viewed-feature-select.png new file mode 100644 index 000000000..66d98acba Binary files /dev/null and b/workshop/3-Experimentation/images/amplitude/product-viewed-feature-select.png differ diff --git a/workshop/3-Experimentation/images/amplitude/search-funnel-post-dates.png b/workshop/3-Experimentation/images/amplitude/search-funnel-post-dates.png new file mode 100644 index 000000000..11c9a9242 Binary files /dev/null and b/workshop/3-Experimentation/images/amplitude/search-funnel-post-dates.png differ diff --git a/workshop/3-Experimentation/images/amplitude/search-funnel-post-setup.png b/workshop/3-Experimentation/images/amplitude/search-funnel-post-setup.png new file mode 100644 index 000000000..a02d91ea6 Binary files /dev/null and b/workshop/3-Experimentation/images/amplitude/search-funnel-post-setup.png differ diff --git a/workshop/3-Experimentation/images/amplitude/search-funnel-post.png b/workshop/3-Experimentation/images/amplitude/search-funnel-post.png new file mode 100644 index 000000000..f2dfe24b0 Binary files /dev/null and b/workshop/3-Experimentation/images/amplitude/search-funnel-post.png differ diff --git a/workshop/3-Experimentation/images/amplitude/search-funnel-pre-dates.png b/workshop/3-Experimentation/images/amplitude/search-funnel-pre-dates.png new file mode 100644 index 000000000..5a90c18cf Binary files /dev/null and b/workshop/3-Experimentation/images/amplitude/search-funnel-pre-dates.png differ diff --git a/workshop/3-Experimentation/images/amplitude/search-funnel-pre-pers.png b/workshop/3-Experimentation/images/amplitude/search-funnel-pre-pers.png new file mode 100644 index 000000000..54eb94789 Binary files /dev/null and b/workshop/3-Experimentation/images/amplitude/search-funnel-pre-pers.png differ diff --git a/workshop/3-Experimentation/images/amplitude/search-funnel-pre-setup.png b/workshop/3-Experimentation/images/amplitude/search-funnel-pre-setup.png new file mode 100644 index 000000000..5d54cab72 Binary files /dev/null and b/workshop/3-Experimentation/images/amplitude/search-funnel-pre-setup.png differ diff --git a/workshop/3-Experimentation/images/amplitude/workshop-architecture.png b/workshop/3-Experimentation/images/amplitude/workshop-architecture.png new file mode 100644 index 000000000..740c7bc35 Binary files /dev/null and b/workshop/3-Experimentation/images/amplitude/workshop-architecture.png differ diff --git a/workshop/4-Messaging/4.2-Braze.ipynb b/workshop/4-Messaging/4.2-Braze.ipynb new file mode 100644 index 000000000..6ba595d57 --- /dev/null +++ b/workshop/4-Messaging/4.2-Braze.ipynb @@ -0,0 +1,522 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Retail Demo Store Messaging Workshop - Braze\n", + "\n", + "In this workshop we will use [Braze](https://braze.com/) to add the ability to personalize marketing messages to customers of the Retail Demo Store using customer behavioral data and the Personalize models you trained in the prior workshops. We are going to define a campaign in Braze to send target users an email with product recommendations from the Amazon Personalize campaign we created in the Personalization workshop earlier. You will also create an email template using the Braze Connected Content feature. When the campaign is launched, Connected Content will send emails to the users that belong in the target group. These emails will be formatted using the Personalize Campaign you created earlier.\n", + "\n", + "Recommended Time: 1 hour\n", + "\n", + "## Prerequisites\n", + "\n", + "This module uses Amazon Personalize to generate and associate personalized product recommendations for users. The content of this workshop is presented with the assumption that you have either completed the [Personalization](../1-Personalization/1.1-Personalize.ipynb) workshop or those resources have been pre-provisioned in your AWS environment. If you are unsure and attending an AWS managed event such as a workshop, check with your event lead.\n", + "\n", + "This part of the workshop requires access to a Braze account. If you do not have a Braze account, please contact your Braze representative. We also assume that you have completed at least the [Getting Started Braze LAB course](https://lab.braze.com/).\n", + "\n", + "You will need to create a Braze API key for the duration of the workshop. This API key will be used to create a set of temporary users for the workshop. These temporary users will be deleted at the end of the workshop." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Architecture\n", + "\n", + "Before you set up Braze to send personalized messages to users, let's review the relevant parts of the Retail Demo Store architecture and how it uses Braze to integrate with the machine learning campaigns created in Personalize.\n", + "\n", + "![Braze Personalization Architecture](images/braze-personalize-arch.png)\n", + "\n", + "Braze will send emails to your users based on their behavior or based on attributes of their user profiles. A discussion of Braze campaigns is beyond the scope of this document but Braze will ingest real-time events from mobile and web applications. This data can be used to identify users and to build user profiles that can be used to determine when to message or email users. \n", + "\n", + "This event data flow will happen in parallel to the same behavioral event data being sent to Amazon Personalize. In this workshop, the demo store uses Amplify to send events to Personalize as shown in the diagram. This data is used to train a recommendations model that can in turn be used by Braze Connected Content to personalize content to users of your mobile and web applications when your Braze campaign runs. \n", + "\n", + "Braze Connected content will be able to get these recommendations via a recommendation service running in AWS. Earlier in this workshop, this service was deployed using by the Cloud Formation templates you used to deploy the workshop environment in ECS." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Building a Braze Email Template\n", + "\n", + "Before you can run a campaign in Braze, you will need to create an email template that will be used to email your users. This template will use the Braze Connected Content feature to fetch product recommendations for the users you will be emailing via your campaign.\n", + "\n", + "First, you will need to get the name of the load balancer for the Retail Demo Store recommendations service you deployed at the beginning of the Retail Demo Store workshop.\n", + "\n", + "The following code will query the AWS account you are using for this workshop, get a list of load balancers, and find the one that belongs to the Retail Demo Store and has the tag `recommendations`. This URL will be stored in the `recommendations_elb_domain_name` variable after you run this code." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import boto3\n", + "\n", + "elbv2 = boto3.client('elbv2')\n", + " \n", + "recommendations_elb_domain_name = None\n", + " \n", + "elbs_paginator = elbv2.get_paginator('describe_load_balancers')\n", + "for elbs_page in elbs_paginator.paginate():\n", + " for elb in elbs_page['LoadBalancers']:\n", + " tags_response = elbv2.describe_tags(ResourceArns = [ elb['LoadBalancerArn'] ])\n", + " for tag_desc in tags_response['TagDescriptions']:\n", + " for tag in tag_desc['Tags']:\n", + " if tag['Key'] == 'RetailDemoStoreServiceName' and tag['Value'] == 'recommendations':\n", + " recommendations_elb_domain_name = elb['DNSName']\n", + " break\n", + " if recommendations_elb_domain_name:\n", + " break\n", + " if recommendations_elb_domain_name:\n", + " break\n", + " if recommendations_elb_domain_name:\n", + " break\n", + " \n", + "assert recommendations_elb_domain_name is not None, 'Unable to find Recommendations service ELB'\n", + " \n", + "print('Recommendations DNS name: {}'.format(recommendations_elb_domain_name))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Next, you are going to prepare the HTML content for the email itself. For simplicity, this workshop will only support HTML email templates. The template provided with this workshop comes from the Braze sample templates, and can be easily adapted to other platforms if needed.\n", + "\n", + "The following code will modify the `./braze-templates/braze-connected-content-email-template.html` template file in the workbook directory you found this notebook in. This code will insert the correct Connected Content code for you, and will prepare a ZIP file which you will use in a moment to upload your template to your Braze campaign. Make sure that you have your Jupyter directory tab open to the directory which contains this notebook." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import zipfile\n", + "\n", + "template_file_path = './braze-templates/braze-connected-content-email-template.html'\n", + "zip_file_path = './braze-templates/braze-connected-content-email-template.zip'\n", + "\n", + "begin = '{% connected_content http://'\n", + "end = '/recommendations?userID={{${user_id} | remove_first: \"test_user_rds_\"}}&fullyQualifyImageUrls=1&numResults=4 :save result %}'\n", + "\n", + "connected_content_call = f'{begin}{recommendations_elb_domain_name}{end}'\n", + "\n", + "text = open(template_file_path).read()\n", + "new_text = '\\n'.join(connected_content_call if line.startswith('{% connected_content') else line for line in text.splitlines())\n", + "open(template_file_path, 'w').write(new_text)\n", + "\n", + "zipfile.ZipFile(zip_file_path, mode='w').write(template_file_path)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Once the above code has completed, check that there is a `braze-connected-content-email-template.zip` in the `./braze-templates` directory in Jupyter. You will need this file when you configure a campaign in the following steps. Once you have confirmed that this file is in the `./braze-templates` directory, download it to your computer:\n", + "\n", + "![](./images/braze-jupyter-download.png)\n", + "\n", + "### Anatomy of a Braze Connected Content Template\n", + "\n", + "Before you create your campaign, let's quickly review how the Connected Content template you just built works.\n", + "\n", + "The template is an HTML file that uses the [Braze Liquid](https://www.braze.com/docs/user_guide/personalization_and_dynamic_content/liquid/) templating language to format product recommendation results from a Braze Connected Content call.\n", + "\n", + "The Connected Content call happens at the very top of the HTML template:\n", + "\n", + "```\n", + "{% connected_content http:///recommendations?userID={{${user_id} | remove_first: \"test_user_rds_\"}}&fullyQualifyImageUrls=1&numResults=4 :save result %}\n", + "```\n", + "\n", + "This line tells Braze to call the Retail Demo Store recommendation REST service when building the email for the user specified in `user_id`. This user ID is stripped of its `test_user_rds_` prefix using a Braze Liquid template function. This is done because the Retail Demo Store uses integer user IDs, and the users you will upload will be created with string user identifiers prefixed with a workshop-specific identifier. This was done for the workshop so that your Braze account does not encounter user ID conflicts with existing user IDs. This is not strictly required for a production deployment of Amazon Personalize and Braze.\n", + "\n", + "When the Connected Content call returns successfully, the JSON results returned from the recommendation service are stored in the variable `result` via the `:save` function." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### What Does the Recommendations Service Return?\n", + "\n", + "In order to understand how the Connected Content template works, it is helpful to take a look at the responses that are returned from the recommendations service. \n", + "\n", + "The code below will invoke your recommendations service, and return a response for one of the users that exists in the Retail Demo Store user database. You can change the `user_id` below to any valid ID.\n", + "\n", + "If this code fails with `recommendations_elb_domain_name` not defined, you will need to re-run the recommendations service code two cells before this one.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import requests\n", + "import json\n", + "\n", + "user_id = 10\n", + "response = requests.get('http://{}/recommendations?userID={}&fullyQualifyImageUrls=1&numResults=4'.format(recommendations_elb_domain_name, user_id))\n", + "print(json.dumps(response.json(), indent = 2))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The return value from the recommendation service should look like:\n", + "\n", + "```\n", + "[\n", + " {\n", + " \"product\": {\n", + " \"id\": \"2\",\n", + " \"url\": \"http://recs.cloudfront.net/#/product/2\",\n", + " \"sk\": \"\",\n", + " \"name\": \"Striped Shirt\",\n", + " \"category\": \"apparel\",\n", + " \"style\": \"shirt\",\n", + " \"description\": \"A classic look for the summer season.\",\n", + " \"price\": 9.99,\n", + " \"image\": \"http://recs.cloudfront.net/images/apparel/1.jpg\",\n", + " \"featured\": \"true\"\n", + " }\n", + " },\n", + " {\n", + " \"product\": {\n", + " \"id\": \"1\",\n", + " \"url\": \"http://recs.cloudfront.net/#/product/1\",\n", + " \"sk\": \"\",\n", + " \"name\": \"Black Leather Backpack\",\n", + " \"category\": \"accessories\",\n", + " \"style\": \"bag\",\n", + " \"description\": \"Our handmade leather backpack will look great at the office or out on the town.\",\n", + " \"price\": 109.99,\n", + " \"image\": \"http://recs.cloudfront.net/images/accessories/1.jpg\",\n", + " \"featured\": \"true\"\n", + " }\n", + " },\n", + "\n", + " ...\n", + "]\n", + "```\n", + "\n", + "Setting up a similar service in your deployment of Amazon Personalize will give you the ability to surface product recommendations directly into Braze and other services that require real-time user personalization such as a mobile application or a web application. \n", + "\n", + "The recommendation service deployed in the workshop queries a Personalize campaign with the `user_id` that you provide. Personalize will return a list of recommended product IDs for the user. The service then looks up the catalog ID for each product and decorates the response with the product URL, image, name, etc. These attributes should be available in your catalog management system. In the case of the Retail Demo Store, there is a catalog service that provides (simulated) catalog items for the workshop. The advantage of a service like this is that your target services like Braze do not need to do an additional lookup in order to get the information they require in order to surface a recommendation to a user, such as product name and the image associated with the product.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### How Does the Template Use the Returned Results?\n", + "\n", + "Once the `result` variable is populated with the above JSON, the template can reference the product data in the variable using the Braze Liquid templating language.\n", + "\n", + "A product cell in the template might look like this:\n", + "\n", + "```\n", + "\n", + "\t\n", + "\t\t\n", + "\t\t\n", + "\t\t\n", + "\t\n", + "
{{result[1].product.name}}{{result[1].product.price}}
\n", + "```\n", + "\n", + "Note the `result[1].product.name` variable will be replaced with the name of the product that is first in the list of products returned, and the same goes for `result[1].product.price` for that product's price. The same can be done for other product attributes such as the product's image." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Creating a Braze Campaign\n", + "\n", + "In a separate browser window or tab, log in to the Braze [console](https://dashboard.braze.com/auth). You will need to have a Braze account configured for you to continue this workshop, or use your existing Braze account.\n", + "\n", + "![Braze Login Screen](images/braze-create-template-0.png)\n", + "\n", + "On the left hand pane of the Braze console, click the Templates & Media link. This will show the Templates list screen. Click the From File button under Basic Email Templates at the top of the screen.\n", + "\n", + "![](images/braze-create-template-1.png)\n", + "\n", + "Click the Upload from File button:\n", + "\n", + "![](images/braze-create-template-2.png)\n", + "\n", + "Navigate to the directory in which you downloaded the `braze-connected-content-email-template.zip` file you created earlier. Click the Open or Save button to upload the ZIP file to Braze.\n", + "\n", + "![](images/braze-create-template-3.png)\n", + "\n", + "Braze will validate the file, and return a status window. Click the Build Email button to continue.\n", + "\n", + "![](images/braze-create-template-4.png)\n", + "\n", + "On the next screen, give the template a name, such as `Braze Connected Content Workshop Template`.\n", + "\n", + "![](images/braze-create-template-5.png)\n", + "\n", + "On the bottom right of the screen, click the Save Template button. \n", + "\n", + "Make sure to click the Save button often, the editor will not auto-save your changes!\n", + "\n", + "![](images/braze-create-template-6.png)\n", + "\n", + "Scroll back up and click the Edit Email Body link\n", + "\n", + "![](images/braze-create-template-7.png)\n", + "\n", + "This will take you to the HTML template editor. \n", + "\n", + "At the very top, you should see the Connected Content call that calls the Personalize recommendation service, with the URL of your deployment of the recommendations service.\n", + "\n", + "![](images/braze-create-template-8.png)\n", + "\n", + "When you are finished looking at your template, click the Done button at the bottom right of the editor screen.\n", + "\n", + "![](images/braze-create-template-9.png)\n", + "\n", + "Before navigating away from the template editor, make sure to click the Save Template button on the bottom right of the template window, or your earlier changes will be lost.\n", + "\n", + "![](images/braze-create-template-10.png)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Create Braze User Profiles\n", + "\n", + "Before you can run a campaign from Braze, you will need to create a set of demo user profiles with valid emails in Braze. \n", + "\n", + "For this workshop, we recommend using a set of test emails that (preferably) map to your own email address or to an email address that you can control.\n", + "\n", + "The following code will use the Braze Users API to create 5 test users in your Braze account. These users will all have a dummy user ID that starts with `test_user_rds`. This will allow us to separate these profiles from other user IDs that you may have in Braze, and will allow us to easily delete the test users later.\n", + "\n", + "For this code to work, you will need to get:\n", + "\n", + "1. Your Braze endpoint URL, you can look this up [here](https://www.braze.com/docs/api/basics/#api-definitions). Once you have your endpoint, set the `braze_endpoint_url` variable to the URL from the documentation.\n", + "2. Your Braze API key. We recommend creating an API key just for this workshop, with the `users.track` and the `users.delete` permission at a minimum. This key can be deleted after your are done with the workshop. Change the `braze_api_key` variable below to your API key value.\n", + "3. Your email. Your email account will need to support plus email addressing for this workshop to work correctly. Input your email into the `your_email` variable below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import requests\n", + "\n", + "braze_endpoint_url = ''\n", + "braze_api_key = ' If you are participating in an AWS managed event such as a workshop and using an AWS provided temporary account, you can skip the following cleanup steps unless otherwise instructed." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3.8.2 64-bit ('3.8.2': pyenv)", + "language": "python", + "name": "python38264bit382pyenv01d5460373a046fa83348829a36ebabe" + }, + "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.8.2-final" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/workshop/4-Messaging/braze-templates/braze-connected-content-email-template.html b/workshop/4-Messaging/braze-templates/braze-connected-content-email-template.html new file mode 100644 index 000000000..c660fc684 --- /dev/null +++ b/workshop/4-Messaging/braze-templates/braze-connected-content-email-template.html @@ -0,0 +1,291 @@ +{% connected_content http://< YOUR RECOMMENDATION SERVICE URL GOES HERE >/recommendations?userID={{${user_id} | remove_first: "test_user_rds_"}}&fullyQualifyImageUrls=1&numResults=4 :save result %} + + + + + + + + + + + + + + + + Braze Products Template + + + + + + + + + + +
+ + + + +
+ + + + + + + +
+
+
+ + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + +
+ + + + +
+ + + + + + + +
+
+
+ + + + + + +
{{result[0].product.name}}{{result[0].product.price}}
+
+
+
+
+ + + + +
+ + + + + + + +
+ + + + + + +
{{result[1].product.name}}{{result[1].product.price}}
+
+
+
+
+ + + + + + +
+ + + + +
+ + + + + + + +
+ + + + + + +
{{result[2].product.name}}{{result[2].product.price}}
+
+
+
+
+ + + + +
+ + + + + + + +
+ + + + + + +
{{result[3].product.name}}{{result[3].product.price}}
+
+
+
+
+ + + + + +
+ + + + +
+
+
+ + + + +
+ +
+
+
+ +
+
+ + +
+
+ + \ No newline at end of file diff --git a/workshop/4-Messaging/images/braze-campaign-1.png b/workshop/4-Messaging/images/braze-campaign-1.png new file mode 100644 index 000000000..d950149c2 Binary files /dev/null and b/workshop/4-Messaging/images/braze-campaign-1.png differ diff --git a/workshop/4-Messaging/images/braze-campaign-10.png b/workshop/4-Messaging/images/braze-campaign-10.png new file mode 100644 index 000000000..7e5eb701f Binary files /dev/null and b/workshop/4-Messaging/images/braze-campaign-10.png differ diff --git a/workshop/4-Messaging/images/braze-campaign-11.png b/workshop/4-Messaging/images/braze-campaign-11.png new file mode 100644 index 000000000..54510d81a Binary files /dev/null and b/workshop/4-Messaging/images/braze-campaign-11.png differ diff --git a/workshop/4-Messaging/images/braze-campaign-2.png b/workshop/4-Messaging/images/braze-campaign-2.png new file mode 100644 index 000000000..f97552599 Binary files /dev/null and b/workshop/4-Messaging/images/braze-campaign-2.png differ diff --git a/workshop/4-Messaging/images/braze-campaign-3.png b/workshop/4-Messaging/images/braze-campaign-3.png new file mode 100644 index 000000000..fbcb29a86 Binary files /dev/null and b/workshop/4-Messaging/images/braze-campaign-3.png differ diff --git a/workshop/4-Messaging/images/braze-campaign-4.png b/workshop/4-Messaging/images/braze-campaign-4.png new file mode 100644 index 000000000..cfddcd96f Binary files /dev/null and b/workshop/4-Messaging/images/braze-campaign-4.png differ diff --git a/workshop/4-Messaging/images/braze-campaign-5.png b/workshop/4-Messaging/images/braze-campaign-5.png new file mode 100644 index 000000000..15c846fbc Binary files /dev/null and b/workshop/4-Messaging/images/braze-campaign-5.png differ diff --git a/workshop/4-Messaging/images/braze-campaign-6.png b/workshop/4-Messaging/images/braze-campaign-6.png new file mode 100644 index 000000000..e5d0d51eb Binary files /dev/null and b/workshop/4-Messaging/images/braze-campaign-6.png differ diff --git a/workshop/4-Messaging/images/braze-campaign-7.png b/workshop/4-Messaging/images/braze-campaign-7.png new file mode 100644 index 000000000..6a3b53d0e Binary files /dev/null and b/workshop/4-Messaging/images/braze-campaign-7.png differ diff --git a/workshop/4-Messaging/images/braze-campaign-8.png b/workshop/4-Messaging/images/braze-campaign-8.png new file mode 100644 index 000000000..875319c2c Binary files /dev/null and b/workshop/4-Messaging/images/braze-campaign-8.png differ diff --git a/workshop/4-Messaging/images/braze-campaign-9.png b/workshop/4-Messaging/images/braze-campaign-9.png new file mode 100644 index 000000000..ef2e2bb30 Binary files /dev/null and b/workshop/4-Messaging/images/braze-campaign-9.png differ diff --git a/workshop/4-Messaging/images/braze-create-segment-1.png b/workshop/4-Messaging/images/braze-create-segment-1.png new file mode 100644 index 000000000..80f270b4a Binary files /dev/null and b/workshop/4-Messaging/images/braze-create-segment-1.png differ diff --git a/workshop/4-Messaging/images/braze-create-segment-2.png b/workshop/4-Messaging/images/braze-create-segment-2.png new file mode 100644 index 000000000..1450eccec Binary files /dev/null and b/workshop/4-Messaging/images/braze-create-segment-2.png differ diff --git a/workshop/4-Messaging/images/braze-create-segment-3.png b/workshop/4-Messaging/images/braze-create-segment-3.png new file mode 100644 index 000000000..8e7fce3b4 Binary files /dev/null and b/workshop/4-Messaging/images/braze-create-segment-3.png differ diff --git a/workshop/4-Messaging/images/braze-create-template-0.png b/workshop/4-Messaging/images/braze-create-template-0.png new file mode 100644 index 000000000..9c78c0e70 Binary files /dev/null and b/workshop/4-Messaging/images/braze-create-template-0.png differ diff --git a/workshop/4-Messaging/images/braze-create-template-1.png b/workshop/4-Messaging/images/braze-create-template-1.png new file mode 100644 index 000000000..05048a0ab Binary files /dev/null and b/workshop/4-Messaging/images/braze-create-template-1.png differ diff --git a/workshop/4-Messaging/images/braze-create-template-10.png b/workshop/4-Messaging/images/braze-create-template-10.png new file mode 100644 index 000000000..345c6ea3b Binary files /dev/null and b/workshop/4-Messaging/images/braze-create-template-10.png differ diff --git a/workshop/4-Messaging/images/braze-create-template-2.png b/workshop/4-Messaging/images/braze-create-template-2.png new file mode 100644 index 000000000..68d69132c Binary files /dev/null and b/workshop/4-Messaging/images/braze-create-template-2.png differ diff --git a/workshop/4-Messaging/images/braze-create-template-3.png b/workshop/4-Messaging/images/braze-create-template-3.png new file mode 100644 index 000000000..d205345d2 Binary files /dev/null and b/workshop/4-Messaging/images/braze-create-template-3.png differ diff --git a/workshop/4-Messaging/images/braze-create-template-4.png b/workshop/4-Messaging/images/braze-create-template-4.png new file mode 100644 index 000000000..0335f0755 Binary files /dev/null and b/workshop/4-Messaging/images/braze-create-template-4.png differ diff --git a/workshop/4-Messaging/images/braze-create-template-5.png b/workshop/4-Messaging/images/braze-create-template-5.png new file mode 100644 index 000000000..157de0e09 Binary files /dev/null and b/workshop/4-Messaging/images/braze-create-template-5.png differ diff --git a/workshop/4-Messaging/images/braze-create-template-6.png b/workshop/4-Messaging/images/braze-create-template-6.png new file mode 100644 index 000000000..6d9313bb3 Binary files /dev/null and b/workshop/4-Messaging/images/braze-create-template-6.png differ diff --git a/workshop/4-Messaging/images/braze-create-template-7.png b/workshop/4-Messaging/images/braze-create-template-7.png new file mode 100644 index 000000000..483b85c6b Binary files /dev/null and b/workshop/4-Messaging/images/braze-create-template-7.png differ diff --git a/workshop/4-Messaging/images/braze-create-template-8.png b/workshop/4-Messaging/images/braze-create-template-8.png new file mode 100644 index 000000000..77ff3c184 Binary files /dev/null and b/workshop/4-Messaging/images/braze-create-template-8.png differ diff --git a/workshop/4-Messaging/images/braze-create-template-9.png b/workshop/4-Messaging/images/braze-create-template-9.png new file mode 100644 index 000000000..ad5edcf16 Binary files /dev/null and b/workshop/4-Messaging/images/braze-create-template-9.png differ diff --git a/workshop/4-Messaging/images/braze-email-example.jpg b/workshop/4-Messaging/images/braze-email-example.jpg new file mode 100644 index 000000000..99c9eb527 Binary files /dev/null and b/workshop/4-Messaging/images/braze-email-example.jpg differ diff --git a/workshop/4-Messaging/images/braze-jupyter-download.png b/workshop/4-Messaging/images/braze-jupyter-download.png new file mode 100644 index 000000000..ac79590fc Binary files /dev/null and b/workshop/4-Messaging/images/braze-jupyter-download.png differ diff --git a/workshop/4-Messaging/images/braze-personalize-arch.png b/workshop/4-Messaging/images/braze-personalize-arch.png new file mode 100644 index 000000000..e6bcda0a9 Binary files /dev/null and b/workshop/4-Messaging/images/braze-personalize-arch.png differ diff --git a/workshop/4-Messaging/images/braze-upload-users-1.png b/workshop/4-Messaging/images/braze-upload-users-1.png new file mode 100644 index 000000000..f57a28d07 Binary files /dev/null and b/workshop/4-Messaging/images/braze-upload-users-1.png differ diff --git a/workshop/4-Messaging/images/braze-upload-users-2.png b/workshop/4-Messaging/images/braze-upload-users-2.png new file mode 100644 index 000000000..03ea9e400 Binary files /dev/null and b/workshop/4-Messaging/images/braze-upload-users-2.png differ diff --git a/workshop/4-Messaging/images/braze-upload-users-3.png b/workshop/4-Messaging/images/braze-upload-users-3.png new file mode 100644 index 000000000..0c71225c4 Binary files /dev/null and b/workshop/4-Messaging/images/braze-upload-users-3.png differ diff --git a/workshop/Welcome.ipynb b/workshop/Welcome.ipynb index 086be5bfe..f296e3333 100644 --- a/workshop/Welcome.ipynb +++ b/workshop/Welcome.ipynb @@ -18,6 +18,12 @@ "\n", "![Retail Demo Store Architecture](./images/retaildemostore-architecture.png)\n", "\n", + "## Personalization and Messaging Architecture\n", + "\n", + "The Retail Demo Store provides personalized user experiences in the user interface using Amazon Personalize. Amazon Pinpoint and Braze are integrated into the Retail Demo Store workshop environment to provide personalized email messaging capabilities via Amazon Personalize.\n", + "\n", + "![Retail Demo Store Personalization Architecture](./images/retail-demo-store-personalization-architecture.png)\n", + "\n", "### Microservices\n", "\n", "The **[Users](https://github.com/aws-samples/retail-demo-store/tree/master/src/users)**, **[Products](https://github.com/aws-samples/retail-demo-store/tree/master/src/products)**, **[Carts](https://github.com/aws-samples/retail-demo-store/tree/master/src/carts)**, and **[Orders](https://github.com/aws-samples/retail-demo-store/tree/master/src/orders)** web services located in ECS rectangle in the above diagram provide access to retrieving and updating each respective entity type. These services are built with the [Golang](https://golang.org/) programming language and provide very basic implementations.\n", @@ -124,12 +130,22 @@ "\n", "**[Open Workshop](./3-Experimentation/3.1-Overview.ipynb)**\n", "\n", + "Additionally, the Experimentation Workshop shows you how to set up [Amplitude](https://amplitude.com) user beahvior analytics to measure the performance of personalization features exposed in the Retail Demo Store, using Amazon Personalize.\n", + "\n", + "**[Open Workshop](./3-Experimentation/3.5-Amplitude-Performance-Metrics.ipynb)**\n", + "\n", "### Messaging Workshop\n", "\n", - "The Messaging Workshop will walk you through adding outbound messaging functionality to the Retail Demo Store. For example, the [Amazon Pinpoint Workshop](./4-Messaging/4.1-Pinpoint.ipynb) demonstrates how to implement dynamic messaging campaigns to automatically send welcome emails, abandoned cart emails, and emails with personalized product recommendations using the same machine learning model used to provide product recommendations in the web application.\n", + "The Messaging Workshop will walk you through adding outbound messaging functionality to the Retail Demo Store:\n", + "\n", + "[Amazon Pinpoint Workshop](./4-Messaging/4.1-Pinpoint.ipynb) demonstrates how to implement dynamic messaging campaigns to automatically send welcome emails, abandoned cart emails, and emails with personalized product recommendations using the same machine learning model used to provide product recommendations in the web application.\n", "\n", "**[Open Pinpoint Workshop](./4-Messaging/4.1-Pinpoint.ipynb)**\n", "\n", + "[Braze Workshop](./4-Messaging/4.2-Braze.ipynb) demonstrates how to implement email campaigns that use Personalize product recommendations to personalize content for Retail Demo Store users.\n", + "\n", + "**[Open Braze Workshop](./4-Messaging/4.2-Braze.ipynb)**\n", + "\n", "### Conversational AI Workshop\n", "\n", "The Conversational AI Workshop will show you how to add a chatbot to the web application using [Amazon Lex](https://aws.amazon.com/lex/). The chatbot will support basic intents/actions such as providing a simple greeting or the store's return policy as well as a more sophisticated product recommendation intent that is powered by a Lambda function will retrieves recommendations from Amazon Personalize.\n", @@ -169,4 +185,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/workshop/images/retail-demo-store-personalization-architecture.png b/workshop/images/retail-demo-store-personalization-architecture.png new file mode 100644 index 000000000..1b15d4891 Binary files /dev/null and b/workshop/images/retail-demo-store-personalization-architecture.png differ diff --git a/workshop/images/retaildemostore-architecture.png b/workshop/images/retaildemostore-architecture.png index 5b73ed7b1..4dfb4ed43 100644 Binary files a/workshop/images/retaildemostore-architecture.png and b/workshop/images/retaildemostore-architecture.png differ