""" Reported Effects and Aromas Prediction Model Copyright (c) 2022 Cannlytics Authors: Keegan Skeate Created: 5/13/2022 Updated: 6/1/2022 License: MIT License Description: This methodology estimates the probability of a review containing a specific aroma or effect. The methodology is then saved in a re-usable model that can predict potential aromas and effects given lab results for strains, flower products, etc. Data Sources: - Data from: Over eight hundred cannabis strains characterized by the relationship between their subjective effects, perceptual profiles, and chemical compositions URL: License: CC BY 4.0. Resources: - Over eight hundred cannabis strains characterized by the relationship between their psychoactive effects, perceptual profiles, and chemical compositions URL: - Effects of cannabidiol in cannabis flower: Implications for harm reduction URL: """ # Standard imports. from datetime import datetime import os from typing import Any, Optional # External imports. from dotenv import dotenv_values import pandas as pd # Internal imports. from cannlytics.firebase import ( initialize_firebase, update_documents, ) from cannlytics.stats import ( calculate_model_statistics, estimate_discrete_model, get_stats_model, predict_stats_model, upload_stats_model, ) from cannlytics.utils import ( snake_case, combine_columns, nonzero_columns, nonzero_rows, sum_columns, download_file_from_url, unzip_files, ) # Ignore convergence errors. import warnings from statsmodels.tools.sm_exceptions import ConvergenceWarning warnings.simplefilter('ignore', ConvergenceWarning) warnings.simplefilter('ignore', RuntimeWarning) # Decarboxylation rate. Source: DECARB = 0.877 # TODO: It would be worthwhile to parse effects and aromas # ourselves with NLP. Sometimes effects may be mentioned # but not a negative. For example,"helped with my anxiety." def download_strain_review_data( data_dir: str, url: Optional[str] = 'https://md-datasets-cache-zipfiles-prod.s3.eu-west-1.amazonaws.com/6zwcgrttkp-1.zip', ): """Download historic strain review data. First, creates the data directory if it doesn't already exist. Second, downloads the data to the given directory. Third, unzips the data and returns the directories. Source: "Data from: Over eight hundred cannabis strains characterized by the relationship between their subjective effects, perceptual profiles, and chemical compositions". URL: License: CC BY 4.0. """ if not os.path.exists(data_dir): os.makedirs(data_dir) download_file_from_url(url, destination=data_dir) unzip_files(data_dir) # Optional: Get the directories programmatically. strain_folder = 'Strain data/strains' compound_folder = 'Terpene and Cannabinoid data' return {'strains': strain_folder, 'compounds': compound_folder} def curate_lab_results( data_dir: str, compound_folder: Optional[str] = 'Terpene and Cannabinoid data', cannabinoid_file: Optional[str] = 'rawDATACana', terpene_file: Optional[str] = 'rawDATATerp', max_cannabinoids: Optional[int] = 35, max_terpenes: Optional[int] = 8, ): """Curate lab results for effects prediction model. Args: data_dir (str): The data where the raw lab results live. compound_folder (str): The folder where the cannabinoid and terpene data live. cannabinoid_file (str): The name of the raw cannabinoid text file. terpene_file (str): The name of the raw terpene text file. max_cannabinoids (int): The maximum value for permissible cannabinoid tests. max_terpenes (int): The maximum value for permissible terpene tests. Returns: (DataFrame): Returns the lab results. """ # Rename any oddly named columns. rename = { 'cb_da': 'cbda', 'cb_ga': 'cbda', 'delta_9_th_ca': 'delta_9_thca', 'th_ca': 'thca', 'caryophylleneoxide': 'caryophyllene_oxide', '3_carene': 'carene', } # Read terpenes. terpenes = None if terpene_file: file_path = os.path.join(data_dir, compound_folder, terpene_file) terpenes = pd.read_csv(file_path, index_col=0) terpenes.columns = [snake_case(x).strip('x_') for x in terpenes.columns] terpenes.rename(columns=rename, inplace=True) terpene_names = list(terpenes.columns[3:]) compounds = terpenes # Read cannabinoids. cannabinoids = None if cannabinoid_file: file_path = os.path.join(data_dir, compound_folder, cannabinoid_file) cannabinoids = pd.read_csv(file_path, index_col=0) cannabinoids.columns = [snake_case(x).strip('x_') for x in cannabinoids.columns] cannabinoids.rename(columns=rename, inplace=True) cannabinoid_names = list(cannabinoids.columns[3:]) compounds = cannabinoids # Merge terpenes and cannabinoids. if terpene_file and cannabinoid_file: compounds = pd.merge( left=cannabinoids, right=terpenes, left_on='file', right_on='file', how='left', suffixes=['', '_terpene'] ) # Combine identical cannabinoids. compounds = combine_columns(compounds, 'thca', 'delta_9_thca') cannabinoid_names.remove('delta_9_thca') # Combine identical terpenes. compounds = combine_columns(compounds, 'p_cymene', 'pcymene') compounds = combine_columns(compounds, 'beta_caryophyllene', 'caryophyllene') compounds = combine_columns(compounds, 'humulene', 'alpha_humulene') terpene_names.remove('pcymene') terpene_names.remove('caryophyllene') terpene_names.remove('alpha_humulene') # Sum ocimene. analytes = ['ocimene', 'beta_ocimene', 'trans_ocimene'] compounds = sum_columns(compounds, 'ocimene', analytes, drop=False) compounds.drop(columns=['beta_ocimene', 'trans_ocimene'], inplace=True) terpene_names.remove('beta_ocimene') terpene_names.remove('trans_ocimene') # Sum nerolidol. analytes = ['trans_nerolidol', 'cis_nerolidol', 'transnerolidol_1', 'transnerolidol_2'] compounds = sum_columns(compounds, 'nerolidol', analytes) terpene_names.remove('trans_nerolidol') terpene_names.remove('cis_nerolidol') terpene_names.remove('transnerolidol_1') terpene_names.remove('transnerolidol_2') terpene_names.append('nerolidol') # Code missing values as 0. compounds = compounds.fillna(0) # Calculate totals. compounds['total_terpenes'] = compounds[terpene_names].sum(axis=1).round(2) compounds['total_cannabinoids'] = compounds[cannabinoid_names].sum(axis=1).round(2) # Calculate total THC, CBD, and CBG. # TODO: Optimize? compounds.loc[compounds['thca'] == 0, 'total_thc'] = compounds['delta_9_thc'].round(2) compounds.loc[compounds['thca'] != 0, 'total_thc'] = (compounds['delta_9_thc'] + compounds['thca'].mul(DECARB)).round(2) compounds.loc[compounds['cbda'] == 0, 'total_cbd'] = compounds['cbd'].round(2) compounds.loc[compounds['cbda'] != 0, 'total_cbd'] = (compounds['cbd'] + compounds['cbda'].mul(DECARB)).round(2) compounds.loc[compounds['cbga'] == 0, 'total_cbg'] = compounds['cbg'].round(2) compounds.loc[compounds['cbga'] != 0, 'total_cbg'] = (compounds['cbg'] + compounds['cbga'].mul(DECARB)).round(2) # Calculate terpinenes total. analytes = ['alpha_terpinene', 'gamma_terpinene', 'terpinolene', 'terpinene'] compounds = sum_columns(compounds, 'terpinenes', analytes, drop=False) # Exclude outliers. compounds = compounds.loc[ (compounds['total_cannabinoids'] < max_cannabinoids) & (compounds['total_terpenes'] < max_terpenes) ] # Clean and return the data. extraneous = ['type', 'file', 'tag_terpene', 'type_terpene'] compounds.drop(columns=extraneous, inplace=True) compounds.rename(columns={'tag': 'strain_name'}, inplace=True) compounds['strain_name'] = compounds['strain_name'].str.replace('-', ' ').str.title() return compounds def curate_strain_reviews( data_dir: str, results: Any, strain_folder: Optional[str] = 'Strain data/strains', ): """Curate cannabis strain reviews. Args: data_dir (str): The directory where the data lives. results (DataFrame): The curated lab result data. strain_folder (str): The folder where the review data lives. Returns: (DataFrame): Returns the strain reviews. """ # Create a panel of reviews of strain lab results. panel = pd.DataFrame() for _, row in results.iterrows(): # Read the strain's effects and aromas data. review_file = row.name.lower().replace(' ', '-') + '.p' file_path = os.path.join(data_dir, strain_folder, review_file) try: strain = pd.read_pickle(file_path) except FileNotFoundError: print("Couldn't find:", file_path) continue # Assign dummy variables for effects and aromas. reviews = strain['data_strain'] name = strain['strain'] category = list(strain['categorias'])[0] for n, review in enumerate(reviews): # Create panel observation, combining prior compound data. obs = row.copy() for aroma in review['sabores']: key = 'aroma_' + snake_case(aroma) obs[key] = 1 for effect in review['efectos']: key = 'effect_' + snake_case(effect) obs[key] = 1 # Assign category determined from original authors NLP. obs['category'] = category obs['strain_name'] = row.name obs['review'] = review['reporte'] obs['user'] = review['usuario'] # Record the observation. obs.name = name + '-' + str(n) obs = obs.to_frame().transpose() panel = pd.concat([panel, obs]) # Return the panel with null effects and aromas coded as 0. return panel.fillna(0) def download_dataset(name, destination): """Download a Cannlytics dataset by its name and given a destination. Args: name (str): A dataset short name. destination (str): The path to download the data for it to live. """ short_url = f'https://cannlytics.page.link/{name}' download_file_from_url(short_url, destination=destination) #----------------------------------------------------------------------- # Tests #----------------------------------------------------------------------- if __name__ == '__main__': #------------------------------------------------------------------- # Curate the strain lab result data. #------------------------------------------------------------------- print('Testing...') DATA_DIR = '../../../.datasets/subjective-effects' # Optional: Download the original data. # download_strain_review_data(DATA_DIR) # Curate the lab results. print('Curating strain lab results...') results = curate_lab_results(DATA_DIR) # Average results by strain, counting the number of tests per strain. strain_data = results.groupby('strain_name').mean() strain_data['tests'] = results.groupby('strain_name')['cbd'].count() strain_data['strain_name'] = strain_data.index # Save the lab results and strain data. # today = datetime.now().isoformat()[:10] # results.to_excel(DATA_DIR + f'/psi-labs-results-{today}.xlsx') # strain_data.to_excel(DATA_DIR + f'/strain-avg-results-{today}.xlsx') #------------------------------------------------------------------- # # Initialize Firebase. # env_file = '../../../.env' # config = dotenv_values(env_file) # bucket_name = config['FIREBASE_STORAGE_BUCKET'] # db = initialize_firebase( # env_file=env_file, # bucket_name=bucket_name, # ) # Upload the strain data to Firestore. # docs = strain_data.to_dict(orient='records') # refs = [f'public/data/strains/{x}' for x in strain_data.index] # update_documents(refs, docs, database=db) # print('Updated %i strains.' % len(docs)) # Upload individual lab results for each strain. # Future work: Format the lab results as metrics with CAS, etc. # results['id'] = results.index # results['lab_id'] = 'SC-000005' # results['lab_name'] = 'PSI Labs' # docs = results.to_dict(orient='records') # refs = [f'public/data/strains/{x[0]}/strain_lab_results/lab_result_{x[1]}' for x in results[['strain_name', 'id']].values] # update_documents(refs, docs, database=db) # print('Updated %i strain lab results.' % len(docs)) #------------------------------------------------------------------- # Curate the strain review data. #------------------------------------------------------------------- # # Curate the reviews. print('Curating reviews...') reviews = curate_strain_reviews(DATA_DIR, strain_data) # Combine `effect_anxiety` and `effect_anxious`. reviews = combine_columns(reviews, 'effect_anxious', 'effect_anxiety') # # Optional: Save and read back in the reviews. today = datetime.now().isoformat()[:10] datafile = DATA_DIR + f'/strain-reviews-{today}.xlsx' reviews.to_excel(datafile) # datafile = DATA_DIR + '/strain-reviews-2022-06-01.xlsx' # reviews = pd.read_excel(datafile, index_col=0) # # Optional: Upload strain review data to Firestore. # reviews['id'] = reviews.index # docs = reviews.to_dict(orient='records') # refs = [f'public/data/strains/{x[0]}/strain_reviews/strain_review_{x[1]}' for x in reviews[['strain_name', 'id']].values] # # update_documents(refs, docs, database=db) #------------------------------------------------------------------- # Future work: Programmatically upload the datasets to Storage. # Optional: Download the pre-compiled data from Cannlytics. # strain_data = download_dataset('strains', DATA_DIR) # reviews = download_dataset('strain-reviews', DATA_DIR) #------------------------------------------------------------------- # Fit the model with the training data. #------------------------------------------------------------------- # Specify different prediction models. # Future work: Logit, cannabinoid / terpene ratios, and bayesian models. # Handle `minor` cannabinoids in `totals` and perhaps `simple` models # (i.e. `total_cannabinoids` - `total_thc` - `total_cbd`). variates = { 'full': [ 'delta_9_thc', 'cbd', 'cbn', 'cbg', 'cbc', 'thcv', 'cbda', 'delta_8_thc', 'cbga', 'thca', 'd_limonene', 'beta_myrcene', 'beta_pinene', 'linalool', 'alpha_pinene', 'camphene', 'carene', 'alpha_terpinene', 'ocimene', 'eucalyptol', 'gamma_terpinene', 'terpinolene', 'isopulegol', 'geraniol', 'humulene', 'guaiol', 'caryophyllene_oxide', 'alpha_bisabolol', 'beta_caryophyllene', 'p_cymene', 'terpinene', 'nerolidol', ], 'terpene_only': [ 'd_limonene', 'beta_myrcene', 'beta_pinene', 'linalool', 'alpha_pinene', 'camphene', 'carene', 'alpha_terpinene', 'ocimene', 'eucalyptol', 'gamma_terpinene', 'terpinolene', 'isopulegol', 'geraniol', 'humulene', 'guaiol', 'caryophyllene_oxide', 'alpha_bisabolol', 'beta_caryophyllene', 'p_cymene', 'terpinene', 'nerolidol', ], 'cannabinoid_only': [ 'delta_9_thc', 'cbd', 'cbn', 'cbg', 'cbc', 'thcv', 'cbda', 'delta_8_thc', 'cbga', 'thca', ], 'totals': [ 'total_terpenes', 'total_thc', 'total_cbd', ], 'simple': [ 'total_thc', 'total_cbd', ], } # # Use the data to create an effect prediction model. # model_name = 'full' # aromas = [x for x in reviews.columns if x.startswith('aroma')] # effects = [x for x in reviews.columns if x.startswith('effect')] # Y = reviews[aromas + effects] # X = reviews[variates[model_name]] # print('Estimating model:', model_name) # effects_model = estimate_discrete_model(X, Y) # # Calculate statistics for the model. # model_stats = calculate_model_statistics(effects_model, Y, X) # # Look at the expected probability of an informed decision. # stat = 'informedness' # print( # f'Mean {stat}:', # round(model_stats.loc[model_stats[stat] < 1][stat].mean(), 4) # ) # # Save the model. # ref = f'public/models/effects/{model_name}' # model_data = upload_stats_model( # effects_model, # ref, # name=model_name, # stats=model_stats, # data_dir=DATA_DIR, # ) # print('Effects prediction model saved:', ref) #------------------------------------------------------------------- # Optional: Use the model to predict the sample and save the # predictions for easy access in the future. #------------------------------------------------------------------- # # Optional: Save the official strain predictions. # predictions = predict_stats_model(effects_model, X, model_stats['threshold']) # predicted_effects = predictions.apply(nonzero_rows, axis=1) # strain_effects = predicted_effects.to_frame() # strain_effects['strain_name'] = reviews['strain_name'] # strain_effects = strain_effects.groupby('strain_name').first() # refs = [f'public/data/strains/{x}' for x in strain_effects.index] # docs = [{ # 'predicted_effects': [y for y in x[0] if y.startswith('effect')], # 'predicted_aromas': [y for y in x[0] if y.startswith('aroma')], # } for x in strain_effects.values] # for i, doc in enumerate(docs): # stats = {} # outcomes = doc['predicted_effects'] + doc['predicted_aromas'] # for outcome in outcomes: # stats[outcome] = model_stats.loc[outcome].to_dict() # docs[i]['model_stats'] = stats # docs[i]['model'] = model_name # update_documents(refs, docs) # print('Updated %i strain predictions.' % len(docs)) #------------------------------------------------------------------- # How to use the model in the wild: `full` model. #------------------------------------------------------------------- # # 1. Get the model and its statistics. # model_name = 'full' # model_ref = f'public/models/effects/{model_name}' # model_data = get_stats_model(model_ref, data_dir=DATA_DIR) # model_stats = model_data['model_stats'] # models = model_data['model'] # thresholds = model_stats['threshold'] # # 2. Predict a single sample (below are mean concentrations). # strain_name = 'Test Sample' # x = pd.DataFrame([{ # 'delta_9_thc': 10.85, # 'cbd': 0.29, # 'cbn': 0.06, # 'cbg': 0.54, # 'cbc': 0.15, # 'thcv': 0.07, # 'cbda': 0.40, # 'delta_8_thc': 0.00, # 'cbga': 0.40, # 'thca': 8.64, # 'd_limonene': 0.22, # 'beta_ocimene': 0.05, # 'beta_myrcene': 0.35, # 'beta_pinene': 0.12, # 'linalool': 0.07, # 'alpha_pinene': 0.10, # 'camphene': 0.01, # 'carene': 0.00, # 'alpha_terpinene': 0.00, # 'ocimene': 0.00, # 'cymene': 0.00, # 'eucalyptol': 0.00, # 'gamma_terpinene': 0.00, # 'terpinolene': 0.80, # 'isopulegol': 0.00, # 'geraniol': 0.00, # 'humulene': 0.06, # 'nerolidol': 0.01, # 'guaiol': 0.01, # 'caryophyllene_oxide': 0.00, # 'alpha_bisabolol': 0.03, # 'beta_caryophyllene': 0.18, # 'alpha_humulene': 0.03, # 'p_cymene': 0.00, # 'terpinene': 0.00, # }]) # prediction = predict_stats_model(models, x, thresholds) # outcomes = nonzero_columns(prediction) # effects = [x for x in outcomes if x.startswith('effect')] # aromas = [x for x in outcomes if x.startswith('aroma')] # print(f'Predicted effects:', effects) # print(f'Predicted aromas:', aromas) # # 3. Save / log the prediction and model stats. # timestamp = datetime.now().isoformat()[:19] # data = { # 'predicted_effects': effects, # 'predicted_aromas': aromas, # 'lab_results': x.to_dict(orient='records')[0], # 'strain_name': strain_name, # 'timestamp': timestamp, # 'model': model_name, # 'model_stats': model_stats, # } # ref = 'models/effects/model_predictions/%s' % (timestamp.replace(':', '-')) # update_documents([ref], [data]) #------------------------------------------------------------------- # How to use the model in the wild: `simple` model. #------------------------------------------------------------------- # # 1. Get the model and its statistics. # model_name = 'simple' # model_ref = f'public/models/effects/{model_name}' # model_data = get_stats_model(model_ref, data_dir=DATA_DIR) # model_stats = model_data['model_stats'] # models = model_data['model'] # thresholds = model_stats['threshold'] # # 2. Predict samples. # x = pd.DataFrame([ # {'total_cbd': 1.8, 'total_thc': 18.0}, # {'total_cbd': 1.0, 'total_thc': 20.0}, # {'total_cbd': 1.0, 'total_thc': 30.0}, # {'total_cbd': 7.0, 'total_thc': 7.0}, # ]) # prediction = predict_stats_model(models, x, thresholds) # outcomes = pd.DataFrame() # for index, row in prediction.iterrows(): # print(f'\nSample {index}') # print('-----------------') # for i, key in enumerate(row['predicted_effects']): # tpr = round(model_stats['true_positive_rate'][key] * 100, 2) # fpr = round(model_stats['false_positive_rate'][key] * 100, 2) # title = key.replace('effect_', '').replace('_', ' ').title() # print(title, f'(TPR: {tpr}%, FPR: {fpr}%)') # outcomes = pd.concat([outcomes, pd.DataFrame([{ # 'tpr': tpr, # 'fpr': fpr, # 'name': title, # 'strain_name': index, # }])]) #------------------------------------------------------------------- # Example visualization of the predicted outcomes. #------------------------------------------------------------------- # # Setup plotting style. # import seaborn as sns # import matplotlib.pyplot as plt # import matplotlib.patches as mpatches # plt.style.use('fivethirtyeight') # plt.rcParams.update({ # 'font.family': 'Times New Roman', # }) # # Create the plot. # outcomes.sort_values('tpr', ascending=False, inplace=True) # colors = sns.color_palette('Spectral', n_colors=12) # colors = [colors[x] for x in [9, 3, 1, 10]] # sns.catplot( # x='name', # y='tpr', # hue='strain_name', # data=outcomes, # kind='bar', # legend=False, # aspect=12/8, # palette=colors, # ) # handles = [] # ratios = ['10:1', '20:1', '30:1', '1:1'] # for i, ratio in enumerate(ratios): # patch = mpatches.Patch(color=colors[i], label=ratio) # handles.append(patch) # plt.legend( # loc='upper right', # title='THC:CBD', # handles=handles, # ) # plt.title('Predicted Effects That May be Reported') # plt.ylabel('True Positive Rate') # plt.xlabel('Predicted Effect') # plt.xticks(rotation=90) # plt.show() #------------------------------------------------------------------- # Fin. #------------------------------------------------------------------- print('Test finished.')