# ---
# jupyter:
#   jupytext:
#     text_representation:
#       extension: .py
#       format_name: light
#       format_version: '1.5'
#       jupytext_version: 1.14.4
#   kernelspec:
#     display_name: Python 3
#     language: python
#     name: python3
# ---

# # Beer Recipe Recommendation Engine

# This notebook performs analysis on scraped webpages from beersmith.com. Beersmith is a website for hobby home brewers to share recipes, and for other brewers to rate the shared recipes. If we organize the results into a User x Recipe matrix, we can create an SVD Recommendation Engine in line with the winning solution of the Netflix Prize. In this analysis, we ignore the cold start problem as we have insufficient data to handle it.
#
# To perform the majority of analysis, I have decided to use the Surprise library by <NAME>. The library is built in a style similar to sci-kit learn, but is designed specifically for recommendation engines. The main difference is the handling of missing data. Sci-kit assumes no missing data in the predictive algorithms, which is not possible for recommmendation engines. Surprise assumes the majority of entries are missing. 

# ## Importing Data

# I created a custom parser to deal with the webpages. Beautiful Soup struggled to deal with the BeerXML format in which the recipes are stored online, and the BeerXML library struggled to deal with webpages. It was easier to create a custome parser rather than trying to get the two libraries to work together.

# +
import pandas as pd
import numpy as np
import os
import random
from collections import defaultdict

import matplotlib.pyplot as plt
import seaborn as sns

from surprise import Reader
from surprise.dataset import Dataset

from surprise.prediction_algorithms.baseline_only import BaselineOnly
from surprise.prediction_algorithms.knns import KNNBasic
from surprise.prediction_algorithms.knns import KNNWithMeans
from surprise.prediction_algorithms.matrix_factorization import SVD
from surprise.prediction_algorithms.matrix_factorization import SVDpp

from surprise.model_selection import train_test_split
from surprise.model_selection import RepeatedKFold
from surprise.model_selection import KFold

from surprise.model_selection.search import GridSearchCV
from surprise.evaluate import GridSearch

from surprise import accuracy

from src.Parser import Parser
# %matplotlib inline 

# +
fls    = os.listdir('recipes/')
receps = []

for fl in fls:
    beer = Parser('recipes/' + fl)
    if beer.ratings is not None:
        receps.append(beer)
# -

ratings = [beer.ratings for beer in receps]
ratings = pd.concat(ratings)
ratings.to_csv('ratings.csv')
ratings = ratings[[x in ['10', '20', '30', '40', '50'] for x in ratings.Rating]]
ratings['Rating'] = pd.to_numeric(ratings['Rating']) / 10
ratings = ratings[['User', 'Recipe', 'Rating']]

# ## Preliminary Exploratory Analysis

# Before creating the train-test splits, perform a cursory EDA to make sure that there are no glaring errors. This dataset is fairly simple as we only care about the fields | User | Item | Rating |. We are interested to see that ratings are integers, and that we have a wide coverage of users and items. Additionally, we would like users to rate multiple recipes so that we can infer preferences. 

ratings.head()

print('Observations Collected:\t' + str(len(fls)))
print('\nUnique Useable Recipes:\t' + str(len(set(ratings.Recipe))))
print('\nUnique Users:\t' + str(len(set(ratings.User))))
print('\nTotal Shape:\t' + str(ratings.shape[0]) + '\n\n')
print(ratings.head())
print('\n')
print(ratings.Rating.plot(kind = 'hist', title = 'Distribution of Ratings'))

# It looks like we collected data on 1,678 recipes. Of those, 915 had at least 1 rating. There were 402 unique recipes, and 708 users provided the ratings. The ratings are integers from 1 - 5, with the majority of ratings being 4 - 5. With some knowledge of homebrewing, the high ratings are somewhat intuitive. If a user puts in 5 weeks of time creating a new batch, they may have some biases that cause them to rate the product highly. 

# ### Most Active Users and Most Rated Recipes

# I suspect that the most active users make up a large portion of the total comments in line with Power Laws. Similarly, the most rated recipes likely dominate the total count of ratings. Examine how extreme this effect is. 

ratings.\
    groupby('Recipe')['User'].\
           count().\
                sort_values(ascending=False).\
                           head(10)

ratings.\
    groupby('User')['Recipe'].\
           count().\
                sort_values(ascending=False).\
                           head(10)

# It appears that brewdog is extremely active compared to the top 10 active users, and that recipe 1,645 was the most rated by a large margin. The converse of this is that there are likely a large number of thinly rated recipes, or inactive users. Out of conservatism, look to increase regularization. 

print(ratings.Rating.describe())
print(set(ratings.Rating))

pd.DataFrame({'Recipe' : pd.to_numeric(ratings['Recipe']), 'Rating' : ratings.Rating}).corr()

# ## Benchmark

# We now have an idea of the distribution of the data. The next step is to set aside data for a test set, to create a benchmark for the cross validation set. The recipe number corresponds to the number used by Beersmith to store the recipe. After some spot checks, it appears that the number roughly corresponds to time the recipe was uploaded. After a quick correlation analysis, this number is uncorrelated with the rating. We should assume IID data, and used K Fold cross validation to make the best use of the data. To avoid bad samples, and because the dataset is small, use Repeated K-Fold cross validation. 
#
# Additionally, we should establish a test set, and only use the test set to approximate the model error on new data. Due to the small data, 75% and 25% test seems reasonable. We will optimize the model on RMSE in line with the Netflix solution. 

# +
reader = Reader(rating_scale=(1, 5))
data   = Dataset.load_from_df(ratings, reader)

random.seed(42)
random.shuffle(data.raw_ratings)
cut_off = int(len(data.raw_ratings) * 0.75)

train_ratings = data.raw_ratings[:cut_off]
test_ratings  = data.raw_ratings[cut_off:]

data.raw_ratings = train_ratings


# -

def evaluator(algo, df, cv_method, verbose = False):
    """
    wrapper to streamline evaluation
    """

    results = []    
    cntr    = 0
    for train_set, test_set in cv_method.split(df):
        algo.fit(train_set)
        predictions = algo.test(test_set)
        accrcy      = accuracy.rmse(predictions)
        results.append(pd.DataFrame({'Fold'  : [cntr], 
                                     'RMSE'  : [accrcy]}))
        cntr += 1
    
    results = pd.concat(results)
    
    if verbose:
        return results
    else:
        return [results['RMSE'].mean(), results['RMSE'].std()]


# +
#Algos 
cv_method    = RepeatedKFold(
                      n_splits = 5, 
                      n_repeats = 3, 
                      random_state = 42)

algo         = BaselineOnly(verbose = False)  

#CV
cv_score     = evaluator(algo, 
                         data, 
                         cv_method)
#Bias
train_set    = data.build_full_trainset()
algo.fit(train_set)
preds        = algo.test(train_set.build_testset())
bias         = accuracy.rmse(preds)

#Error
test_set     = data.construct_testset(test_ratings)
preds        = algo.test(test_set)
err          = accuracy.rmse(preds)

#Baseline CV
print('\nBaseline CV avg:\t' + str(cv_score[0]))
print('\nBaseline CV std:\t' + str(cv_score[1]))
print('\nBaseline bias:\t' + str(bias))
print('\nBaseline OOS error:\t' + str(err))
# -

# Our baseline model uses the average gloval rating, the average recipe rating, and the average user rating in the train set, to make a prediction on the validation and test sets. Using this simple approach, we can get a cross validation score of 0.725, and the baseline OOS error is 0.812, which is just outside of a standard error in the CV. This is reasonable=, proceed with more in depth analysis. 

# ## In Depth EDA

# Now that we have set benchmarks, and set aside the test set, we can perform some more in depth EDA without substantially risking overfitting to the dataset. This mainly involves examining volatile ratings.   

# +
ratings_eda = pd.DataFrame(train_ratings)
ratings_eda = ratings_eda[[0, 1, 2]]
ratings_eda.columns = ['User', 
                       'Recipe',
                       'Rating']
ratings_sd = ratings_eda.groupby('Recipe').agg(['mean', 'std', 'count'])
ratings_sd.columns = ratings_sd.columns.levels[1] 

print('\n')
print('Description of Ratings\n')
print(ratings_sd['count'].describe())
print('\n')
print('Count Frequencies of Number of Recipe Ratings\n')
print(ratings_sd.groupby('count')['mean'].count())
print('\n')
print('Most Volatile Ratings\n')
print(ratings_sd[ratings_sd['count'] > 2].sort_values('std', ascending=False).head(10))
print(ratings_sd['count'].plot(kind = 'hist', title = 'Histogram of Ratings'))
# -

# From our more in depth EDA, most recipes have 1 ratings, while a select few have more than 5. This data is very sparse, and we may be required to get more ratings before progressing further. In later stages of analysis, examine the impact of adding more data. 
#
# In addition, it looks like a few recipes have very different ratings. Recipe 211 for example has 3 ratings, which are 1, 3, 5. In the movie literature, this has become known as the Napolean Dynamite effect, which the item is controversial, so ratings are divided. This is hard to correct for, but may be useful for evaluating the model, and seeing how it responds to these situations.

# ## Model Fitting

# My general model fitting procedure on small datasets is to use repeated K Fold CV, and a grid search over the hyperparameters. On the repeated K Fold, I change the seed so that is is somewhate different than the sets used to select between models. Upon completion of the CV results, explore the distribution across cross sections of hyperparameters to ensure that the final set is around the global minimum. 

cv_method    = RepeatedKFold(n_splits = 5, 
                             n_repeats = 5, 
                             random_state = 1)

# ### KNN Model

# The KNN model identifies users with the most similar ratings to the candiate user, and makes a prediciton using the ratings of those users. With a very sparse dataset, it may be difficult to get a good estimate of the ratings. 

# +
param_grid = {'k'       : [4, 8, 16, 32, 64],
              'min_k'   : [1, 2, 3, 4],
             'verbose'  : [False]}

clf_knn = GridSearchCV(KNNBasic,
                   param_grid,
                   cv = cv_method)

clf_knn.fit(data)
results_df = pd.DataFrame.from_dict(clf_knn.cv_results)
results_df.sort_values('mean_test_rmse', inplace = True)
results_df.head(10)
# -

# ### SVD
#
# The SVD model decomposes the error term of the baseline model into user matrices, and item matrices using the SVD. Regularization is used to limit the size of individual parameters, and the amount of regularization is chosen to minimize RMSE. 

# +
param_grid = {'n_factors'  : np.arange(25, 225, 25),
              'n_epochs'   : np.arange(5, 25, 5),
              'lr_all'     : np.arange(0.02, 0.14, 0.02),
              'reg_all'    : np.arange(0, 0.14, 0.02)}

clf_svd = GridSearchCV(SVD,
                       param_grid,
                       cv = cv_method)
clf_svd.fit(data)

results_df = pd.DataFrame.from_dict(clf_svd.cv_results)
results_df.sort_values('mean_test_rmse', inplace = True)
results_df.head(10)
# -

for par in ['n_factors', 'n_epochs', 'lr_all', 'reg_all']:
    
    fig, (ax1, ax2) = plt.subplots(1, 2)
    sns.boxplot(x= 'param_' + par, y="mean_test_rmse", data = results_df, ax=ax1)
    ax1.set_title('Validation Error')
    
    sns.boxplot(x='param_' + par, y="std_test_rmse", data = results_df, ax=ax2)
    ax2.set_title('Validation Standard Deviation')
    
    plt.show()

# ## SVD Plus Plus
#
# This modification makes an additional adjustment where an additional term is used to capture implicit ratings. This term assumes that the a user rating an item implies they initially had some interest in the model.

# +
param_grid = {'n_factors'  : np.arange(25, 100, 25),
              'n_epochs'   : np.arange(10, 25, 5),
              'lr_all'     : np.arange(0.02, 0.1, 0.02),
              'reg_all'    : np.arange(0.02, 0.1, 0.02)}

clf_svdpp = GridSearchCV(SVDpp,
                   param_grid)

clf_svdpp.fit(data)
results_df = pd.DataFrame.from_dict(clf_svdpp.cv_results)
results_df.sort_values('mean_test_rmse', inplace = True)
results_df.head(10)
# -

# # Identify Meaning of Directions
#
# The SVD algorithm gets us a combination of user X constant X item matrices. If we look at the first directions of the user and item matrices, we may be able to infer certain trends in the items most influencing the direction. Fit a simple model, and let's see if we can identify patterns. 

# ### Items

# +
new_clf = clf_svd.best_estimator['rmse']
train = data.build_full_trainset()
new_clf.fit(train)

train_frame = pd.DataFrame(new_clf.qi[:,:5])
train_frame['Recipe'] = np.nan
for key, value in zip(train._raw2inner_id_items.keys(),
                      train._raw2inner_id_items.values()):
    train_frame['Recipe'].iloc[value] = key


# +
def to_rating(x):
    try:
        x = pd.to_numeric(x) / 10
        x = x.mean()
    except:
        x = None
        
    return x

df_desc = [{'Recipe' : beer.ratings.Recipe.iloc[0],
            'Style'  : beer.style,
            'ABV'    : beer.abv,
            'Taste'  : beer.taste_rat,
            'Bitter' : beer.bitter,
            'Rating' : to_rating(beer.ratings.Rating),
            'Name'   : beer.name} for beer in receps]
df_desc = pd.DataFrame.from_dict(df_desc)

df_desc = pd.merge(train_frame, 
                   df_desc, 
                     how = 'left',
                     on  = 'Recipe')
df_desc['Name'] = [x.split(' - ')[0] for x in df_desc['Name']]
df_desc['Diff'] = df_desc[0] + df_desc[1]
df_desc['Recipe']= [x.replace('recipes/', '') for x in df_desc['Recipe']]
# -

print(df_desc.\
          sort_values(0)[['Recipe', 'Bitter', 'ABV', 'Style', 'Name', 'Rating']].\
              head(10))
print('\n****************************************************************************\n')
print(df_desc.\
          sort_values(0)[['Recipe', 'Bitter', 'ABV', 'Style', 'Name', 'Rating']].\
              tail(10))

# So there is no direct interpretaion of the primary principal direction, only patterns that can be inferred. By taking recipes at the top and bottom of the direction, we try to create patterns and put a name on what is going on in the background. Looking through the names at the top, descriptions using the words 'vanilla', 'citra', and 'ladyface' imply that the recipes are more sweet. This appears to be supported by the generally lower bitterness of the recipes at the top compared to recipes at the bottom. 

print(df_desc.\
          sort_values(1)[['Recipe', 'Bitter', 'ABV', 'Style', 'Name', 'Rating']].\
              head(10))
print('\n****************************************************************************\n')
print(df_desc.\
          sort_values(1)[['Recipe', 'Bitter', 'ABV', 'Style', 'Name', 'Rating']].\
              tail(10))

# Moving to the second direction, we see wheatbeers near the bottom, and a lot of american themed beers at the top. Again, there also appears to be less bitterness at the top as a whole than the bottom. Perhaps this is a more savory direction. 

# ### Users
#
# This is incredibly difficult to determine at the user level, so I will include this for brevity, but skip the major part of the analysis. Users on opposite end of the spectrum should enjoy opposite palletes of beer. 

# +
train_frame = pd.DataFrame(new_clf.pu[:,:5])
train_frame['User'] = np.nan
           
for key, value in zip(train._raw2inner_id_users.keys(),
                      train._raw2inner_id_users.values()):
    train_frame['User'].iloc[value] = key
# -

print(train_frame.\
          sort_values(0)[[0, 1, 'User']].\
              head(10))
print('\n\n')
print(train_frame.\
          sort_values(0)[[0, 1, 'User']].\
              tail(10))


# ## Napoleon Dynamite Effect
#
# The Napolean Dynamite Effect comes from the Netflix Prize, where the participants noticed certain movies had polarizing ratings. We can infer the same pattern here, where we use 5 fold cv to generate pseudo OOS predictions, and identify the cases where those predictions perform the worst. 

# +
new_clf = clf_svd.best_estimator['rmse']
cv      = KFold(n_splits = 5, random_state = 1234)

store   = []
for train_set, test_set in cv.split(data):
    new_clf.fit(train_set)
    predictions = new_clf.test(test_set)
    
    for pred, known in zip(predictions, test_set):
        known = list(known)
        known.append(pred.est)
        store.append(known)
store = [{'User'   : x[0],
          'Recipe' : x[1],
          'True'   : x[2],
          'Pred'   : x[3]} for x in store]
    
store = pd.DataFrame.from_dict(store)
store['error'] = (store['Pred'] - store['True'])**2
store['cnt']   = 1
     
store = store.\
    groupby('Recipe')[['error', 'cnt']].\
           sum().\
              sort_values('error', ascending = False)
# -

print(store[store['cnt'] > 2])

# Recipe 211 showed up when we looked at the standard deviation of ratings. Interestingly, it looks like the algorithm is getting the most rated recipes wrong. This is a sign of overfitting which may be present in the current setup. Perhaps we should lean to algorithms with more bias and lower CV scores. 
#
# # Final Model Selection
#
# In the previous sections, we fit 3 models and established a benchmark. In this section, we pick from thosd models as a final solution. We use the same CV set we used on the benchmark, and set our final model. Then we evaluate the model on the test set to get an estimate of model error. Out of the principal of Ocam's Razor, if we do not exceed benchmark performance, we should prefer the simpler solution.

# +
cv_method    = RepeatedKFold(
                      n_splits = 5, 
                      n_repeats = 10, 
                      random_state = 42)

err = []
for algo in [clf_knn.best_estimator['rmse'],
             clf_svd.best_estimator['rmse'],
             clf_svdpp.best_estimator['rmse']]:            

    #CV
    cv_score     = evaluator(algo, 
                             data, 
                             cv_method)
    #Bias
    train_set    = data.build_full_trainset()
    algo.fit(train_set)
    preds        = algo.test(train_set.build_testset())
    bias         = accuracy.rmse(preds)
    
    #Error
    test_set     = data.construct_testset(test_ratings)
    preds        = algo.test(test_set)
    err.append(accuracy.rmse(preds))
    
    #Baseline CV
    print('\nModel CV avg:\t' + str(cv_score[0]))
    print('\nModel CV std:\t' + str(cv_score[1]))
    print('\nModel bias:\t' + str(bias) + '\n')
# -

# The SVD models perform best, with the SVDpp model showing both the lowest CV score, and lowest standard deviation of the score. In addition, the microscopic bias of the SVD model implies that all of the performance is coming from variance. With a known overfitting issue, I prefer the SVDpp model. 
#
# ### OOS Error
#
# Having established the model, we now look to the OOS error, to see if we have improved on the benchmark error of 0.81229.

print(err)
algo = clf_svdpp.best_estimator['rmse']


# That was a lot of work for a small incremental improvement. Oh well, it looks like the model is an improvment, and that the process is more sound.
#
# # Number of Items to Serve
#
# Having set a model, we look to set the number of items to serve to the client. This is more tied to the business problem than the individual user. For this problem, we are trying to come up with new ideas, so we do not want to serve something that the user has already rated.
#
# The measure of a good number is the recall. Recall is a measure of the percentage of items served to the user, that the user will rate positively. With a continuous rating system, we have to set a threshold to establish what a positive rating is, and vary the number of items and look at how recall evolves with the number of items. We can do this at varying thresholds. 

def precision_recall_at_k(predictions, k=10, threshold=4):
    '''Return precision and recall at k metrics for each user.'''

    # First map the predictions to each user.
    user_est_true = defaultdict(list)
    for uid, _, true_r, est, _ in predictions:
        user_est_true[uid].append((est, true_r))

    precisions = dict()
    recalls = dict()
    for uid, user_ratings in user_est_true.items():

        # Sort user ratings by estimated value
        user_ratings.sort(key=lambda x: x[0], reverse=True)

        # Number of relevant items
        n_rel = sum((true_r >= threshold) for (_, true_r) in user_ratings)

        # Number of recommended items in top k
        n_rec_k = sum((est >= threshold) for (est, _) in user_ratings[:k])

        # Number of relevant and recommended items in top k
        n_rel_and_rec_k = sum(((true_r >= threshold) and (est >= threshold))
                              for (est, true_r) in user_ratings[:k])

        # Precision@K: Proportion of recommended items that are relevant
        precisions[uid] = n_rel_and_rec_k / n_rec_k if n_rec_k != 0 else 1

        # Recall@K: Proportion of relevant items that are recommended
        recalls[uid] = n_rel_and_rec_k / n_rel if n_rel != 0 else 1

    return precisions, recalls


# +
store_outer = []
store_inner = []

data.raw_ratings = train_ratings + test_ratings

cv = KFold()

for num in range(1, 21):
    for cut_off in np.arange(2.5, 5, 0.25):
        store_inner = []
        for trainset, testset in cv.split(data):
            algo.fit(trainset)
            predictions = algo.test(testset)
            precisions, recalls = precision_recall_at_k(predictions, 
                                                        k = num, 
                                                        threshold = cut_off)
            
            #Fix here to add threshold
            store_inner.append(pd.DataFrame({'Precision'  : [sum(prec for prec in precisions.values()) / len(precisions)],
                                        'recalls'    : [sum(rec for rec in recalls.values()) / len(recalls)],
                                        'Count'      : [len(predictions)]}))    
        store_inner = pd.concat(store_inner)
        store_inner['Precision'] = store_inner['Precision'] * store_inner['Count']
        store_inner['recalls']   = store_inner['recalls'] * store_inner['Count']
        store_inner              = store_inner.sum()
        store_inner['Precision'] = store_inner['Precision'] / store_inner['Count']
        store_inner['recalls']   = store_inner['recalls'] / store_inner['Count']
        store_inner['k']         = num
        store_inner['threshold'] = cut_off
        store_outer.append(store_inner)

df_result = pd.concat(store_outer, axis = 1)
df_result = df_result.transpose()
df_result.index = df_result.k
df_result.rename(columns = {'recalls' : 'Recall'}, inplace = True)
df_result[df_result['threshold']==4.5][['Precision', 'Recall']].\
    plot(title = 'Precision and Recall at 4.5 Threshold\nVarying the Number of Items Shown')
# -

# It looks liek 9 is a good trade-off between preserving recall and not underwhelming or overwhelming the user. In production systemss, we can perterb this and measure the effect on KPIs. For this project, we can just hold it as is. 
#
# # Bootstrap Data
#
# After going through the process of model development, the easiest way to improve a model is by increasing the data size. In webscraping, I was told to hold off for a few weeks by the website, so adding more data at the moment is not possible. Examine the impact on the model of increasing the size of the data when we can. 

# +
hold_data = data.raw_ratings

results_l   = []
n_samples   = 500
for ii in [10, 100, 250, 500, 750, 800]:
    results_m   = []
    results_s   = []

    for jj in range(n_samples):    
        random.shuffle(hold_data)
        train_ratings    = hold_data[:ii]
        test_ratings     = hold_data[ii:]
        data.raw_ratings = train_ratings
        algo.fit(data.build_full_trainset())        
        preds        = algo.test(data.construct_testset(test_ratings))        
        err          = accuracy.rmse(preds)

        results_m.append(err)
        results_s.append(err**2)
    results_l.append({'Count' : ii,
                      'Mean'  : sum(results_m) / n_samples,
                      'Second': sum(results_s) / n_samples})
    print(ii)
    
data.raw_ratings = hold_data
df = pd.DataFrame.from_dict(results_l)
df['Training Set Size'] = df['Count']
df.index = df['Training Set Size']
# -

df.Mean.plot()

# It looks like we are still in a phase where we can substantially improve by adding more data. I think when the strongly suggested cooling off period is over, I will continue to scrape the website. 
#
# # Recommendation for a New Brewer
#
# As a the obvious final step, we take a new user and make a suggested list for them. I picked out tx-brewer because he had rated some recipes that looked good to me at a glance. Let's see what else he would like. 

# +
res = []
for key, value in zip(train_set._raw2inner_id_items.keys(),
                      train_set._raw2inner_id_items.values()):
    pred = algo.predict(train_set._raw2inner_id_users['2891/tx-brewer'], 
                        value)
    res.append({'User' : '2891/tx-brewer',
                'Item' : key,
                'Value' :pred.est})
res    = pd.DataFrame.from_dict(res)
prev_serv = ratings[ratings['User'] == '2891/tx-brewer']['Recipe'].values

res    = res[[x not in prev_serv for x in res['Item']]]
res.sort_values('Value', inplace = True, ascending = False)
res    = res.head(30)
res['Item'] = [x.replace('recipes/', '') for x in res['Item']]
res = pd.merge(res,
            df_desc[['Recipe', 'ABV', 'Bitter', 'Name', 'Rating', 'Style', 'Taste']],
            left_on = 'Item', 
            right_on = 'Recipe')
res = res.head(9)
res = res[['Item', 'Recipe', 'User', 'Value', 'ABV', 'Bitter', 'Name', 'Rating', 'Style', 'Taste']]
print('Results for tx-brewer:\n\n')
print(res)
# -

# Interestingly, one of the recipes had an extremely low rating. Upon closer inspection, it looks like the two raters had an opposite pallate to tx-brewer. In addition, there are quite a few wheat beers and American Pale Ales that made it onto the list. This is in line with what the user has historically rated. 

StarcoderdataJupyterScriptsDedupFiltered