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

# # Goodreads Data Analysis
#
# ## I. Imports

import pandas as pd
import numpy as np
import itertools
import datapane as dp
import plotly.graph_objects as go
import plotly.figure_factory as ff
import plotly.express as px

# ## II. Load the data
#
# Here we'll load the csv file and separate its columns into two dataframes. The first one will keep the details about the books that we won't use in the analysis, and the second one will store the data we need for the analysis.

csv = pd.read_csv('./GoodReads_100k_books.csv')
csv.head()

to_analyse = ['title', 'bookformat', 'pages', 'author', 'genre', 'reviews', 'totalratings']
details = ['title', 'desc', 'rating', 'img', 'link']

df_book_details = csv.filter(items=details, axis=1)
df_book_details.head()

df = csv.filter(items=to_analyse, axis=1)
df.head()

# ## III. Preparation
#
#

# ### Basic Information
#
# Firstly we'll print some basic information about the dataframe.

df.dtypes

df.describe()

df['bookformat'].nunique() # Unique values in bookformat column

# Before we can count how many unique values are there in the author and genre columns, we'll have to split the values stored as strings in these columns for each row of the dataframe. Only then we'll be able to treat each author or genre category independently.

# +
split_if_not_missing = lambda w: w if pd.isnull(w) else w.split(',')

df['genre'] = df['genre'].apply(split_if_not_missing)
df['author'] = df['author'].apply(split_if_not_missing)
df.head()


# -

def unique_categories(data_frame, column, sort_ascending=False):
    '''
    Todo: write document string.
    '''
    column_notnull_values = data_frame[data_frame[column].notnull()][column]
    df_cat = pd.DataFrame(
        {
            'count': pd.Series(itertools.chain.from_iterable(column_notnull_values)).value_counts()
        }
    ).sort_values(by='count', ascending=sort_ascending)
    return df_cat


unique_categories(df, 'author').shape[0] # Number of authors

unique_categories(df, 'genre').shape[0] # Number of genres

# ### Missing Values
#
# Now that we already know some basic information about the books dataset, we should search for missing values in order to have them properly treated before the analysis.

df.isna().sum()

# There are no missing values for most variables we are going to use, which is great! However there are some missing values in the bookformat and genre columns. We should also look for rows with 0 in the pages column, since any real book has more than 0 pages, and thus it's more appropriate to treat these cases of 0 pages as missing data.

df = df.replace({'pages': {0: np.nan}})
df.isna().sum()


# There are too many different bookformats, but most of them might actually represent a very small part of the books in our dataset. Let's select only the most representative bookformats and filter the dataframe. Depending on the criteria we chose to select the bookformats, this may also remove all the rows where the format is missing.

def cumulative_percentage(data_frame, column, ascending=False, sufix='cumulative_pc') -> pd.DataFrame:
    '''
    Computes the cumulative percentage for a specified numeric column of a data_frame.
    
    Parameters
    ----------
    data_frame: DataFrame
    column: str
    ascending: bool, default False
        Sort ascending vs descending.
    sufix: str, default 'cumulative_pc'
        String to append to the end of the cumulative percentage column name.
        Sufix is preceded by a underscore by default.
        
    Returns:
        DataFrame: A copy of input data_frame with an aditional column for the cumulative percentage.
    '''
    
    result_df = data_frame.sort_values(by=column, ascending=ascending).reset_index(drop=True)
    result_df[column + '_' + sufix] = result_df[column].cumsum() / result_df[column].sum()
    return result_df


def select_by_representativeness(data_frame, column, mode=1, threshold=0.95, threshold_column=None) -> pd.DataFrame:
    '''
    Selects the most or the least representative categories from column of a data_frame.

    The data is grouped by the column values to measure the value counts or the sum of values from another column, if specified in threshold_column.
    After sorting the data by the measured values, only those that correspond to a fraction smaller than or equal to the threshold will be selected.
    
    Parameters
    ----------
    data_frame: DataFrame
    column: str
        The name of the column from which the categories will be selected.
    mode: 0 or 1, default 1
        Whether to select the most (1) or the least (0) representative categories.
    threshold: {0 to 1}, default 0.95
        Proportion of categories measured values to be selected. If 0, an empty list is returned. If 1, all categories are returned.
    threshold_column str, optional:
        The name of a column with numeric values to be used as measure of representativeness.
        
    Returns:
        DataFrame: A DataFrame with the selected categories from column.
    '''
    if threshold_column is None:
        agg_func = 'count'
        agg_col_name = agg_func
        grouped = data_frame.groupby(column).size().reset_index(name=agg_col_name)
    else:
        agg_func = 'sum'
        agg_col_name = threshold_column + '_' + agg_func
        grouped = data_frame.groupby(column).agg(
            {threshold_column: agg_func}
        ).rename(
            {threshold_column: agg_col_name},
            axis=1
        ).reset_index()
    
    ascending = not bool(mode)
    sufix = 'cumulative_pc'
    grouped = cumulative_percentage(
        data_frame=grouped,
        column=agg_col_name,
        ascending=ascending,
        sufix=sufix
    )
    selected = grouped[grouped[agg_col_name + '_' + sufix] <= threshold]
    
    return selected


representative_bookformats = select_by_representativeness(df, 'bookformat', threshold=0.979)
representative_bookformats

df = df[df['bookformat'].isin(representative_bookformats['bookformat'])]
df.isna().sum()

# Now that only the genre and pages columns still have missing values, we should decide wheter to drop the rows, to input the values, or to let the missing values in the dataframe. Since for this project we won't build a ML model and the data in these columns will be used in different steps of the analysis, we are going to drop the rows, but only those where both the genre and the number of pages are missing.

df = df.dropna(subset=['genre', 'pages'], how='all').reset_index(drop=True)
df.isna().sum()

# ### Additional columns

# Before we can start our analysis, let's create bins for the number of pages of the books to be able to deal with categories instead of having to deal with the numeric variable itself.

# +
bins=list(range(0,1000,100))+[max(df['pages'])]

pages_labels=[]
for i in range(len(bins)-1):
    if i == len(bins)-2:
        pages_labels.append('Over ' + str(bins[i]))
        continue
    pages_labels.append(str(bins[i]) + " to " + str(bins[i+1]))

pages_categories = pd.cut(
    x=df['pages'],
    bins=bins,
    labels=pages_labels
)
df.insert(2, 'pages_cat', pages_categories)
df.head()
# -

# Finally, we need a measure of how much the readers got engaged by the book, so let's add a column named 'readers_engagement' for this feature. We will define it as the sum of reviews and total ratings received by a book on Goodreads.

df['readers_engagement'] = df['reviews'] + df['totalratings']
df.head()

# ## IV. Analysis
#
#

# +
# Here we can see how is the shape of the distribution of the books by number of pages and bookformat.
# It's clearly a skewed distribution.
fig = px.histogram(
    data_frame=df,
    x='pages',
    color='bookformat',
    category_orders=dict(
        bookformat=representative_bookformats['bookformat']
    ),
    barmode='overlay'
)

fig.update_traces(
    xbins={'start': 0, 'end': 5000, 'size': 100}
)

fig.show()
# -

# Distribution of the books by readers engagement values
# Also skewed. Should apply log scale!
fig = px.histogram(df, 'readers_engagement')
fig.show()

# ### 1. How does the book size and the book format affect the readers engagement?

# Compute the median engagement for each book size (pages) interval
pages_vs_engagement = df.groupby('pages_cat').agg({'readers_engagement': 'median'}).reset_index()

# +
# Plot the distribution of the readers engagement for books in each category of size and format

fig = px.box(
    data_frame=df,
    x='pages_cat',
    y='readers_engagement',
    color='bookformat',
    category_orders=dict(
        pages_cat=pages_labels,
        bookformat=representative_bookformats['bookformat']
    ),
    log_y=True,
    labels={
        'pages_cat': 'Book size (pages)',
        'readers_engagement': 'Readers engagement',
        'bookformat': 'Book Format'
    },
    title='Readers engagement by book size and format',
    hover_name='title'
)

fig.add_trace(go.Scatter(
        x=pages_vs_engagement['pages_cat'],
        y=pages_vs_engagement['readers_engagement'],
        mode='markers',
        marker_symbol=41,
        marker_color='black',
        marker_line_width=2,
        marker_size=40,
        name='Median Engagement',
        hovertemplate=
        'Pages: %{x}<br>'+
        'Median Engagement: %{y}'
    )
)

fig.update_layout(
    height=700,
    width=1000,
    legend=dict(
        orientation='h',
        yanchor='bottom',
        y=1.01,
        xanchor='left',
        x=0
    )
)

fig.show()
# -

# Publish to datapane
report = dp.Report(dp.Plot(fig))
report.upload(name='book_popularity_vs_size_and_format', open=True)

# ## 2. How to make a popular genre combination?

# Let's first prepare a new dataframe with unique genres and count how many times they appear in our original dataframe.

genres = unique_categories(df, 'genre').sort_values(by='count', ascending=False)
genres

# +
fig = px.line(
    data_frame=genres,
    x=genres.index,
    y='count',
)

fig.show()


# -

# We will also need an auxiliary dataframe where each colum corresponds to a genre and the values are dummies (0 or 1) that indicate whether a book has been classified with this genre or not.

def cat_dummies_from_lists(cat_lists, unique_cats):
    '''
    Searches each list in cat_lists for the categoric values specified in unique_cats.
    
    Parameters
    ----------
    cat_lists: Series
        A series of lists containing categoric values.
    unique_cats: list
        The categories (unique values) to search in each list from cat_lists.
    
    Returns: DataFrame
        A DataFrame where each row corresponds to a list from cat_lists and each column corresponds to a category.
        The value is 1 if the category was found in that list and 0 if it wasn't.
    '''
    dummies = {}
    for cat in unique_cats:
        dummies[cat] = cat_lists.apply(lambda x: x if x is np.nan else int(cat in x))
    return pd.DataFrame(dummies)


genre_dummies = cat_dummies_from_lists(df['genre'], genres.index.to_list())
genre_dummies

# ### 2.1. Which are the most and the least popular book genres?

# We will first store the sum of readers engagement grouped by the books genre.

genre_engagements = genre_dummies.mul(df['readers_engagement'], axis=0)
genres['total_engagement'] = genre_engagements.sum()
genres['avg_engagement'] = genre_engagements.mean()
genres['dev_from_mean'] = genres['avg_engagement'] - genres['avg_engagement'].mean()
genres = genres.sort_values(by='avg_engagement', ascending=False).reset_index().rename({'index': 'genre'}, axis=1)
genres

# Now we can rank any number of most popular genres. Let's list the top 10 genres with the highest average readers engagement.

# +
popular_n = 10

genres.head(popular_n)['genre']
# -

# In the previous step, 'audiobook' has showed up as one of the top 10 popularity genres. However 'audiobook' would fit better as a category for the books format, so we shouldn't consider it as a valid book genre.

# Remove "Audiobook" from genres
genres = genres[genres['genre'] != 'Audiobook']
genre_dummies = genre_dummies.drop(columns=['Audiobook'])

# +
# Plot the 50 most popular genres
popular_n = 50

fig = px.bar(
    data_frame=genres.head(popular_n),
    x='avg_engagement',
    y='genre',
    orientation='h',
    labels={'genre': 'Genre', 'avg_engagement': 'Average readers engagement', 'total_engagement': 'Total engagement'},
    title='The ' + str(popular_n) + ' most popular genres',
    hover_name='genre',
    hover_data={
        'genre': False,
        'total_engagement': ':,',
        'avg_engagement': ':.2f'
    }
)

fig.update_layout(
    height=900,
    width=900,
    margin={'pad': 10},
    bargap=0.5,
    yaxis={'categoryorder': 'total ascending', 'tickmode': 'linear'}
)

fig.show()

# +
# Plot the complete popularity rank

fig = px.histogram(
    data_frame=genres,
    y='genre',
    x='dev_from_mean',
    orientation='h',
    title='Overview of genre popularity'
)

color = ['SeaGreen' if i < 50 else 'DodgerBlue' if val >= 0 else 'Tomato' for i, val in enumerate(genres['dev_from_mean'])]

fig.update_traces(
    marker=dict(color=color),
    customdata=np.stack((genres.index+1, genres['avg_engagement']), axis=-1),
    hovertemplate=
    '<b>#%{customdata[0]}: %{y}</b><br>' +
    '<br>Popularity (average readers engagement): %{customdata[1]:,.2f}' +
    '<br>Popularity (deviation from mean): %{x:.2f}' +
    '<extra></extra>'
)

fig.update_layout(
    height=800,
    showlegend=False,
    xaxis={
        'title': 'Deviation from mean'
    },
    yaxis={
        'title': 'Genre',
        'autorange': 'reversed'
    }
)


fig.add_hrect(
    y0=genres['genre'].iloc[50],
    y1=genres['genre'].iloc[0],
    line_width=0,
    fillcolor='PaleGreen',
    opacity=0.6,
    layer='below',
    annotation={
        'text': 'Top 50 popular genres',
        'y': genres['genre'].iloc[25],
        'yanchor': 'middle',
        'ay': 0,
        'x': 0.5,
        'xanchor': 'center',
    }
)

fig.show()
# -

# Publish to datapane
report = dp.Report(dp.Plot(fig))
report.upload(name='overview_of_genres_popularity', open=True)

# ### 2.2. How are genres associated to each other?

# Compute correlation between all genres
genres_corr = genre_dummies.corr()

# The correlation between genres will be ploted as a heatmap, but there are too many categories to include in a single heatmap. So let's filter the genres_corr dataframe to get a better visualization of the most popular genres and their relation.

# Leave only the genres with above average readers engagement
corr_filtered_df = genres_corr.filter(genres[genres['dev_from_mean'] >= 0]['genre'], axis = 1)
# Leave only the genres with at least one correlation greater than 0.15 (or lower than -0.15)
corr_filtered_df = corr_filtered_df.where(lambda x: abs(x) >= 0.15).dropna(how='all')
include = corr_filtered_df.index

genres_corr_filtered = genres_corr.filter(include, axis=1).filter(include, axis=0)
genre_dummies_filtered = genre_dummies.filter(include, axis=1)

# In order to visualize more clearly the existing relations between genres, we will clusterize the correlations dataframe before plotting the heatmap.

from scipy.cluster.hierarchy import linkage, fcluster
from scipy.spatial.distance import squareform

# +
dissimilarity = 1 - abs(genres_corr_filtered)
Z = linkage(squareform(dissimilarity), 'complete')

# Clusterize the data
threshold = 0.90
labels = fcluster(Z, threshold, criterion='distance')

# Keep the indices to sort labels
labels_order = np.argsort(labels)

# Build a new dataframe with the sorted columns
for idx, i in enumerate(genre_dummies_filtered.columns[labels_order]):
    if idx == 0:
        clustered = pd.DataFrame(genre_dummies_filtered[i])
    else:
        df_to_append = pd.DataFrame(genre_dummies_filtered[i])
        clustered = pd.concat([clustered, df_to_append], axis=1)

clustered_corr = clustered.corr()

# +
# Plot the heatmap

fig = px.imshow(
    img=round(clustered_corr,2),
    color_continuous_scale='Balance_r',
    range_color=[-1,1],
    title='Genres correlation'
)

fig.update_layout(
    height=900,
    width=900,
    coloraxis=dict(
        colorbar=dict(
            title='Correlation'
        )
    ),
    xaxis=dict(
        showspikes=True,
        spikemode='across'
    ),
    yaxis=dict(
        showspikes=True,
        spikemode='across'
    ),
)

dist_threshold = 7
added_annotations_loc = []
for i, item in enumerate(genres.head(50)['genre']):
    loc = clustered_corr.columns.get_loc(item)
    dist = abs(np.subtract(added_annotations_loc, loc))
    if not any(dist < dist_threshold):
        added_annotations_loc.append(loc)
        fig.add_annotation(
            text=f'#{i+1}: {item}',
            font=dict(
                size=9
            ),
            x=item,
            xanchor='left' if loc < clustered_corr.shape[1]/2 else 'right',
            ax=35 if loc < clustered_corr.shape[1]/2 else -35,
            y=item,
            yanchor='middle',
            ay=0,
            showarrow=True
        )

fig.show()
# -

# Publish to datapane
report = dp.Report(dp.Plot(fig))
report.upload(name='genres_correlation', open=True)



StarcoderdataJupyterScriptsDedupFiltered