Supervised Learning - Foundations Project: ReCell¶
Problem Statement¶
Business Context¶
Buying and selling used phones and tablets used to be something that happened on a handful of online marketplace sites. But the used and refurbished device market has grown considerably over the past decade, and a new IDC (International Data Corporation) forecast predicts that the used phone market would be worth \$52.7bn by 2023 with a compound annual growth rate (CAGR) of 13.6% from 2018 to 2023. This growth can be attributed to an uptick in demand for used phones and tablets that offer considerable savings compared with new models.
Refurbished and used devices continue to provide cost-effective alternatives to both consumers and businesses that are looking to save money when purchasing one. There are plenty of other benefits associated with the used device market. Used and refurbished devices can be sold with warranties and can also be insured with proof of purchase. Third-party vendors/platforms, such as Verizon, Amazon, etc., provide attractive offers to customers for refurbished devices. Maximizing the longevity of devices through second-hand trade also reduces their environmental impact and helps in recycling and reducing waste. The impact of the COVID-19 outbreak may further boost this segment as consumers cut back on discretionary spending and buy phones and tablets only for immediate needs.
Objective¶
The rising potential of this comparatively under-the-radar market fuels the need for an ML-based solution to develop a dynamic pricing strategy for used and refurbished devices. ReCell, a startup aiming to tap the potential in this market, has hired you as a data scientist. They want you to analyze the data provided and build a linear regression model to predict the price of a used phone/tablet and identify factors that significantly influence it.
Data Description¶
The data contains the different attributes of used/refurbished phones and tablets. The data was collected in the year 2021. The detailed data dictionary is given below.
- brand_name: Name of manufacturing brand
- os: OS on which the device runs
- screen_size: Size of the screen in cm
- 4g: Whether 4G is available or not
- 5g: Whether 5G is available or not
- main_camera_mp: Resolution of the rear camera in megapixels
- selfie_camera_mp: Resolution of the front camera in megapixels
- int_memory: Amount of internal memory (ROM) in GB
- ram: Amount of RAM in GB
- battery: Energy capacity of the device battery in mAh
- weight: Weight of the device in grams
- release_year: Year when the device model was released
- days_used: Number of days the used/refurbished device has been used
- normalized_new_price: Normalized price of a new device of the same model in euros
- normalized_used_price: Normalized price of the used/refurbished device in euros
Importing necessary libraries¶
# Installing the libraries with the specified version.
# uncomment and run the following line if Google Colab is being used
# !pip install scikit-learn==1.2.2 seaborn==0.13.1 matplotlib==3.7.1 numpy==1.25.2 pandas==1.5.3 -q --user
# Installing the libraries with the specified version.
# uncomment and run the following lines if Jupyter Notebook is being used
# !pip install scikit-learn==1.2.2 seaborn==0.11.1 matplotlib==3.3.4 numpy==1.24.3 pandas==1.5.2 -q --user
Note: After running the above cell, kindly restart the notebook kernel and run all cells sequentially from the start again.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import pylab
import scipy.stats as stats
import statsmodels.stats.api as sms
from statsmodels.compat import lzip
sns.set()
from sklearn.model_selection import train_test_split
import statsmodels.api as sm
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
import warnings
warnings.filterwarnings("ignore", category=FutureWarning)
tab20_blue = '#1f77b4'
tab20_orange = '#ff7f0e'
tab20_green = '#2ca02c'
tab20_red = '#d62728'
tab20_puple = '#9467bd'
tab20_pink = '#e377c2'
tab20_grey = '#7f7f7f'
tab20_yellow = '#bcbd22'
tab20_teal = '#17becf'
sns.set_style("white")
Loading the dataset¶
data = pd.read_csv('used_device_data.csv')
df = data.copy()
Data Overview¶
- Observations
- Sanity checks
df.shape
(3454, 15)
df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 3454 entries, 0 to 3453 Data columns (total 15 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 brand_name 3454 non-null object 1 os 3454 non-null object 2 screen_size 3454 non-null float64 3 4g 3454 non-null object 4 5g 3454 non-null object 5 main_camera_mp 3275 non-null float64 6 selfie_camera_mp 3452 non-null float64 7 int_memory 3450 non-null float64 8 ram 3450 non-null float64 9 battery 3448 non-null float64 10 weight 3447 non-null float64 11 release_year 3454 non-null int64 12 days_used 3454 non-null int64 13 normalized_used_price 3454 non-null float64 14 normalized_new_price 3454 non-null float64 dtypes: float64(9), int64(2), object(4) memory usage: 404.9+ KB
df.sample(5)
| brand_name | os | screen_size | 4g | 5g | main_camera_mp | selfie_camera_mp | int_memory | ram | battery | weight | release_year | days_used | normalized_used_price | normalized_new_price | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1381 | Huawei | Android | 20.32 | yes | no | 5.0 | 1.0 | 32.0 | 4.0 | 4800.0 | 329.0 | 2014 | 591 | 4.564452 | 5.704249 |
| 2253 | Others | Android | 10.29 | no | no | 8.0 | 0.3 | 16.0 | 4.0 | 2400.0 | 154.0 | 2013 | 1068 | 4.393585 | 5.395172 |
| 1450 | Lava | Android | 12.70 | yes | no | 13.0 | 8.0 | 16.0 | 4.0 | 2500.0 | 128.0 | 2015 | 792 | 4.303119 | 4.794136 |
| 2678 | Sony | Android | 12.70 | yes | no | 19.0 | 5.0 | 64.0 | 4.0 | 2870.0 | 168.0 | 2018 | 703 | 4.502694 | 6.155537 |
| 2129 | Oppo | Android | 15.34 | yes | no | 13.0 | 25.0 | 64.0 | 4.0 | 3600.0 | 156.0 | 2018 | 563 | 4.839768 | 5.349391 |
df.describe(include="all").T
| count | unique | top | freq | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| brand_name | 3454 | 34 | Others | 502 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| os | 3454 | 4 | Android | 3214 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| screen_size | 3454.0 | NaN | NaN | NaN | 13.713115 | 3.80528 | 5.08 | 12.7 | 12.83 | 15.34 | 30.71 |
| 4g | 3454 | 2 | yes | 2335 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 5g | 3454 | 2 | no | 3302 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| main_camera_mp | 3275.0 | NaN | NaN | NaN | 9.460208 | 4.815461 | 0.08 | 5.0 | 8.0 | 13.0 | 48.0 |
| selfie_camera_mp | 3452.0 | NaN | NaN | NaN | 6.554229 | 6.970372 | 0.0 | 2.0 | 5.0 | 8.0 | 32.0 |
| int_memory | 3450.0 | NaN | NaN | NaN | 54.573099 | 84.972371 | 0.01 | 16.0 | 32.0 | 64.0 | 1024.0 |
| ram | 3450.0 | NaN | NaN | NaN | 4.036122 | 1.365105 | 0.02 | 4.0 | 4.0 | 4.0 | 12.0 |
| battery | 3448.0 | NaN | NaN | NaN | 3133.402697 | 1299.682844 | 500.0 | 2100.0 | 3000.0 | 4000.0 | 9720.0 |
| weight | 3447.0 | NaN | NaN | NaN | 182.751871 | 88.413228 | 69.0 | 142.0 | 160.0 | 185.0 | 855.0 |
| release_year | 3454.0 | NaN | NaN | NaN | 2015.965258 | 2.298455 | 2013.0 | 2014.0 | 2015.5 | 2018.0 | 2020.0 |
| days_used | 3454.0 | NaN | NaN | NaN | 674.869716 | 248.580166 | 91.0 | 533.5 | 690.5 | 868.75 | 1094.0 |
| normalized_used_price | 3454.0 | NaN | NaN | NaN | 4.364712 | 0.588914 | 1.536867 | 4.033931 | 4.405133 | 4.7557 | 6.619433 |
| normalized_new_price | 3454.0 | NaN | NaN | NaN | 5.233107 | 0.683637 | 2.901422 | 4.790342 | 5.245892 | 5.673718 | 7.847841 |
df.duplicated().sum()
0
df.isnull().sum()
brand_name 0 os 0 screen_size 0 4g 0 5g 0 main_camera_mp 179 selfie_camera_mp 2 int_memory 4 ram 4 battery 6 weight 7 release_year 0 days_used 0 normalized_used_price 0 normalized_new_price 0 dtype: int64
Exploratory Data Analysis (EDA)¶
- EDA is an important part of any project involving data.
- It is important to investigate and understand the data better before building a model with it.
- A few questions have been mentioned below which will help you approach the analysis in the right manner and generate insights from the data.
- A thorough analysis of the data, in addition to the questions mentioned below, should be done.
EDA¶
Questions:
- What does the distribution of normalized used device prices look like?
- What percentage of the used device market is dominated by Android devices?
- The amount of RAM is important for the smooth functioning of a device. How does the amount of RAM vary with the brand?
- A large battery often increases a device's weight, making it feel uncomfortable in the hands. How does the weight vary for phones and tablets offering large batteries (more than 4500 mAh)?
- Bigger screens are desirable for entertainment purposes as they offer a better viewing experience. How many phones and tablets are available across different brands with a screen size larger than 6 inches?
- A lot of devices nowadays offer great selfie cameras, allowing us to capture our favorite moments with loved ones. What is the distribution of devices offering greater than 8MP selfie cameras across brands?
- Which attributes are highly correlated with the normalized price of a used device?
def histogram_and_boxplot(data, feature, figsize=(10, 5), kde=False, bins=None, box_color=tab20_orange,
hist_color=tab20_blue, showmeans=True, title_fontsize=16, xlabel_fontsize=14,
ylabel_fontsize=14, rotation=0, fontsize=14, labelsize=12):
"""
Boxplot and histogram combined
data: dataframe
feature: dataframe column
figsize: size of figure (default (10,5))
kde: whether to show the density curve (default False)
bins: number of bins for histogram (default None)
box_color: color of boxplot (default "tab20_orange")
hist_color: color of histogram (default "tab20_blue")
title_fontsize: font size for the title (default 16)
xlabel_fontsize: font size for the x-axis label (default 16)
ylabel_fontsize: font size for the y-axis label (default 16)
rotation: rotation of x-axis labels (default 0 degrees)
fontsize: font size of the axis tick labels (default 16)
labelsize: font size of the annotation labels (default 16)
"""
fig, (ax_box, ax_hist) = plt.subplots(
nrows=2,
sharex=True,
gridspec_kw={"height_ratios": (0.25, 0.75)},
figsize=figsize,
)
sns.boxplot(data=data, x=feature, ax=ax_box, showmeans=showmeans, color=box_color)
sns.histplot(data=data, x=feature, kde=kde, ax=ax_hist, bins=bins, color=hist_color
) if bins else sns.histplot(data=data, x=feature, kde=kde, ax=ax_hist, color=hist_color)
mean = data[feature].mean()
median = data[feature].median()
ax_hist.axvline(mean, color=tab20_green, linestyle="--", label=f'Mean: {mean:.2f}')
ax_hist.axvline(median, color=tab20_orange, linestyle="-", label=f'Median: {median:.2f}')
ax_hist.legend(fontsize=labelsize)
ax_box.set(title=f'Distribution of {" ".join(feature.split("_")).lower()}', xlabel='', ylabel='')
ax_box.tick_params(axis='x', rotation=rotation, labelsize=fontsize)
ax_box.tick_params(axis='y', labelsize=fontsize)
ax_hist.set_xlabel(feature, fontsize=xlabel_fontsize)
ax_hist.set_ylabel('Count', fontsize=ylabel_fontsize)
ax_hist.tick_params(axis='x', rotation=rotation, labelsize=fontsize)
ax_hist.tick_params(axis='y', labelsize=fontsize)
plt.show()
def labeled_barplot(data, feature, perc=False, n=None, palette="tab20", figsize=(10, 5),
rotation=90, fontsize=14, labelsize=12, title_fontsize=16,
xlabel_fontsize=14, ylabel_fontsize=14):
"""
Barplot with percentage at the top
data: dataframe
feature: dataframe column
perc: whether to display percentages instead of count (default is False)
n: displays the top n category levels (default is None, i.e., display all levels)
"""
total = len(data[feature])
unique_count = data[feature].nunique()
plot_count = unique_count if n is None else min(n, unique_count)
plt.figure(figsize=(max(plot_count + 2, figsize[0]), figsize[1]))
plt.xticks(rotation=rotation, fontsize=fontsize)
order = data[feature].value_counts().index[:plot_count]
ax = sns.countplot(
data=data,
x=feature,
order=order,
palette=palette
)
for p in ax.patches:
if perc:
label = "{:.1f}%".format(100 * p.get_height() / total)
else:
label = p.get_height()
x = p.get_x() + p.get_width() / 2
y = p.get_height()
ax.annotate(
label,
(x, y),
ha="center",
va="center",
size=labelsize,
xytext=(0, 5),
textcoords="offset points"
)
ax.set_title(f'Percentage of {" ".join(feature.split("_")).lower()} Distribution', fontsize=title_fontsize)
ax.set_xlabel(feature, fontsize=xlabel_fontsize)
ax.set_ylabel('Count' if not perc else 'Percentage', fontsize=ylabel_fontsize)
ax.tick_params(axis='x', rotation=rotation, labelsize=fontsize)
ax.tick_params(axis='y', labelsize=fontsize)
plt.show()
def labeled_barplot_side_by_side(data, feature1, feature2, perc=False, n=None, palette="tab20",
figsize=(10, 5), rotation=90, fontsize=14, labelsize=12,
title_fontsize=16, xlabel_fontsize=14, ylabel_fontsize=14):
"""
Barplot with percentage at the top for two features side by side
data: dataframe
feature1: first dataframe column
feature2: second dataframe column
perc: whether to display percentages instead of count (default is False)
n: displays the top n category levels (default is None, i.e., display all levels)
palette: color palette for the plots (default is "tab20")
figsize: size of the figure (default is (10, 5))
rotation: rotation of x-axis labels (default is 90 degrees)
fontsize: font size of the axis tick labels (default is 16)
labelsize: font size of the annotation labels (default is 16)
title_fontsize: font size of the title (default is 16)
xlabel_fontsize: font size of the x-axis label (default is 16)
ylabel_fontsize: font size of the y-axis label (default is 16)
"""
fig, axs = plt.subplots(1, 2, figsize=figsize)
for ax, feature in zip(axs, [feature1, feature2]):
total = len(data[feature])
unique_count = data[feature].nunique()
plot_count = unique_count if n is None else min(n, unique_count)
order = data[feature].value_counts().index[:plot_count]
sns.countplot(
data=data,
x=feature,
order=order,
palette=palette,
ax=ax
)
for p in ax.patches:
if perc:
label = "{:.1f}%".format(100 * p.get_height() / total)
else:
label = p.get_height()
x = p.get_x() + p.get_width() / 2
y = p.get_height()
ax.annotate(
label,
(x, y),
ha="center",
va="center",
size=labelsize,
xytext=(0, 5),
textcoords="offset points"
)
ax.set_title(f'Percentage of {" ".join(feature.split("_")).lower()}', fontsize=title_fontsize)
ax.set_xlabel(feature, fontsize=xlabel_fontsize)
ax.set_ylabel('Count' if not perc else 'Percentage', fontsize=ylabel_fontsize)
ax.tick_params(axis='x', rotation=rotation, labelsize=fontsize)
ax.tick_params(axis='y', labelsize=fontsize)
plt.tight_layout()
plt.show()
What does the distribution of normalized used device prices look like?
histogram_and_boxplot(df, "normalized_used_price", kde=True)
Observations of the distribution of normalized used device prices:
- The normalized used price is approximately normal with a slight left skew.
- The mean (4.36) is slightly less than the median (4.41).
- Most of the devices fall between 3.5 and 5.
- There are some outliers, mostly on the lower end.
histogram_and_boxplot(df, "normalized_new_price", kde=True)
histogram_and_boxplot(df, "screen_size", kde=True, bins=8)
histogram_and_boxplot(df, "main_camera_mp", kde=True, bins=10)
histogram_and_boxplot(df, "selfie_camera_mp", kde=True, bins=10)
histogram_and_boxplot(df, "int_memory", kde=True,bins=10)
histogram_and_boxplot(df, "battery", kde=True)
histogram_and_boxplot(df, "weight", kde=True)
histogram_and_boxplot(df, "days_used", kde=True)
What percentage of the used device market is dominated by Android devices?
labeled_barplot(df, "os", perc=True)
Observations of Android in the used device market:
Andriod phones make up an overwhelming majority of the used device market with 93.1%. Given the very low percentages of other operating systems we may need to make special considerations when determining if the same factors that drive Android resale value also drive the price for other brands. We may want to look into why there are so many more Androids in this data set. Are there more Androids available or was our data collection biased. Is there a greater demand for Android devices or just higher available. Further analysis is need to determine the driver of the demand and availability of other devices.
Percentage of market by brand:
labeled_barplot(df, "brand_name", perc=True, palette="tab20")
Distribution of RAM broken out by brand name: The amount of RAM is important for the smooth functioning of a device. How does the amount of RAM vary with the brand?
plt.figure(figsize=(14, 8))
sns.violinplot(x='brand_name', y='ram', data=df, palette="tab20", inner="box")
plt.title('Distribution of RAM by Brand')
plt.xlabel('Brand')
plt.ylabel('RAM (GB)')
plt.xticks(rotation=90)
plt.show()
Observations of RAM variance:
- One plus as a wide range in variation of RAM from about 3GB to 14GB
- Apple and many other brands have a narrow range of RAM suggesting the ram is consistent across most devices within brand. Most seem to have one primary configuration then there are very small percentages or outliers of devices that have higher or lower amount of RAM indicated by only having one density peak and some long tails.
Percentage of phones with 4g and 5g:
labeled_barplot_side_by_side(df, '4g', '5g', perc=True, rotation=None)
labeled_barplot(df, "ram", perc=True)
labeled_barplot(df, "release_year", perc=True)
sns.pairplot(data=df[['days_used', 'battery', 'weight', 'normalized_new_price', 'normalized_used_price' ]])
plt.suptitle('Pair plots of continuous variables', fontsize=16)
plt.subplots_adjust(top=0.95)
plt.show()
sns.pairplot(data=df[['release_year', 'screen_size', 'main_camera_mp', 'selfie_camera_mp', 'int_memory', 'normalized_used_price']])
plt.suptitle('Pair plots of discrete variables', fontsize=16)
plt.subplots_adjust(top=0.95)
plt.show()
num_cols = df.select_dtypes(include=np.number).columns.tolist()
plt.figure(figsize=(12, 7))
sns.heatmap(df[num_cols].corr(), annot=True, vmin=-1, vmax=1, fmt=".2f", cmap="Spectral")
plt.suptitle('Correlation heat map', fontsize=16)
plt.subplots_adjust(top=0.9)
plt.show()
Observations¶
- The strongest predictor for used price is the new price. The correlation of 0.83 suggests the as new price increases so does the used price.
- Other features that have a strong positive correlation are battery (0.61), screen size (0.61), main camera mp (0.59), ram (0.52) and release year (0.51). For front and back camera quality, battery capacity, ram and screen size the bigger/higher the value the higher the resale value.
- The main negative correlation is the days used (-0.36) indicating the more days used device is used the lower the resale value.
A large battery often increases a device's weight, making it feel uncomfortable in the hands. How does the weight vary for phones and tablets offering large batteries (more than 4500 mAh)?
plt.figure(figsize=(12, 8))
large_battery_devices = df[df['battery'] > 4500]
sns.violinplot(x='brand_name', y='weight', data=large_battery_devices, palette="Paired", inner="box")
plt.title('Weight Distribution of Phones and Tablets with Batteries > 4500 mAh', fontsize=16)
plt.xlabel('Brand Name')
plt.ylabel('Weight (grams)')
plt.xticks(rotation=90)
plt.show()
Observations¶
- There are several brands that have limited data or do not offer a large capacity battery of 4500 mAh like Oppo, Spice, etc
- Of those that do have large capacity offerering that have the largest variations in weight include Samsung, acer, levano, LG and Asus
- The brands that have the lowest average weight with large capacity battery are Honor, infinix, Xiaomi, and Asus.
plt.figure(figsize=(8, 8))
sns.scatterplot(data=df, x='weight', y='battery', hue='os')
plt.legend(loc="upper left")
plt.title('Distribution of battery and weight by operating system', fontsize=16)
plt.show()
df['screen_size_inches'] = df['screen_size'] * 0.393701
df['device_type'] = df['screen_size_inches'].apply(lambda x: 'Tablet' if x > 7 else 'Phone')
large_screen_devices = df[df['screen_size'] > 6]
plt.figure(figsize=(8, 8))
sns.scatterplot(data=df, x='screen_size_inches', y='battery', hue='os')
plt.legend(loc="upper left")
plt.title('Distribution of screen size in inches and weight by operating system', fontsize=16)
plt.show()
Bigger screens are desirable for entertainment purposes as they offer a better viewing experience. How many phones and tablets are available across different brands with a screen size larger than 6 inches?
plt.figure(figsize=(14, 8))
sns.countplot(data=large_screen_devices, x='brand_name', hue='device_type', palette='tab20')
plt.title('Number of Phones and Tablets with Screen Size > 6 inches Across Brands')
plt.xlabel('Brand')
plt.ylabel('Count')
plt.xticks(rotation=90)
plt.show()
Observations:¶
- Samsung has the most phones with large screens but there are a significant count of devices that have a large screen in the others categroy.
- The phones with the least number of large screen devices are Apple and Infinix.
A lot of devices nowadays offer great selfie cameras, allowing us to capture our favorite moments with loved ones. What is the distribution of devices offering greater than 8MP selfie cameras across brands?
df_melted = df.melt(id_vars=['os'],
value_vars=['main_camera_mp', 'selfie_camera_mp'],
var_name='camera_type',
value_name='megapixels')
plt.figure(figsize=(15, 7))
sns.boxplot(data=df_melted, x='megapixels', y='os', hue='camera_type', palette='tab20')
plt.title('Distribution of Main and Selfie Camera Megapixels by OS', fontsize=16)
plt.xlabel('Megapixels', fontsize=14)
plt.ylabel('OS', fontsize=14)
plt.legend(title='Camera Type', fontsize=12, title_fontsize=14)
plt.tight_layout()
plt.show()
Observations of camera quality:
- Android has the highest megapixel cameras but the range of quality ranges greatly across devices and brands.
- iOS has the highest mean megapixels for main cameras across all devices.
- All brands favor higher quality main camera over selfie camera.
plt.figure(figsize=(15, 5))
sns.boxplot(data=df.sort_values("4g"), x="release_year", y="normalized_used_price", hue="4g", palette="tab20")
plt.show()
plt.figure(figsize=(15, 5))
sns.boxplot(data=df.sort_values("5g"), x="release_year", y="normalized_used_price", hue="5g", palette="tab20")
plt.show()
plt.figure(figsize=(15, 5))
sns.scatterplot(data=df.sort_values("4g"), x="normalized_new_price", y="normalized_used_price", hue="4g", palette="tab20")
plt.show()
plt.figure(figsize=(15, 5))
sns.scatterplot(data=df.sort_values("5g"), x="normalized_new_price", y="normalized_used_price", hue="5g", style='5g', palette="tab20")
plt.show()
sns.lmplot(data=df, x='normalized_new_price', y='normalized_used_price', col='os', hue='os', palette="tab20");
plt.show()
Data Preprocessing¶
- Missing value treatment
- Feature engineering (if needed)
- Outlier detection and treatment (if needed)
- Preparing data for modeling
- Any other preprocessing steps (if needed)
df.isnull().sum()
brand_name 0 os 0 screen_size 0 4g 0 5g 0 main_camera_mp 179 selfie_camera_mp 2 int_memory 4 ram 4 battery 6 weight 7 release_year 0 days_used 0 normalized_used_price 0 normalized_new_price 0 screen_size_inches 0 device_type 0 dtype: int64
df_clean = df.copy()
df_clean['main_camera_mp'] = df['main_camera_mp'].fillna(value=df.groupby(['release_year'])['main_camera_mp'].transform("median"))
df_clean['selfie_camera_mp'] = df['selfie_camera_mp'].fillna(value=df.groupby(['release_year'])['selfie_camera_mp'].transform("median"))
df_clean['int_memory'] = df['int_memory'].fillna(value=df.groupby(['brand_name', 'release_year'])['int_memory'].transform("median"))
df_clean['ram'] = df['ram'].fillna(value=df.groupby(['brand_name', 'release_year'])['ram'].transform("median"))
df_clean['battery'] = df_clean['battery'].fillna(value=df.groupby(['brand_name'])['battery'].transform("median"))
df_clean['weight'] = df_clean['weight'].fillna(value=df.groupby(['brand_name'])['weight'].transform("median"))
df_clean.isnull().sum()
brand_name 0 os 0 screen_size 0 4g 0 5g 0 main_camera_mp 0 selfie_camera_mp 0 int_memory 0 ram 0 battery 0 weight 0 release_year 0 days_used 0 normalized_used_price 0 normalized_new_price 0 screen_size_inches 0 device_type 0 dtype: int64
df_clean['selfie_camera_mp'] = np.ceil(df_clean['selfie_camera_mp'])
df_clean['main_camera_mp'] = np.ceil(df_clean['main_camera_mp'])
df_clean['screen_size_inches'] = np.ceil(df_clean['screen_size_inches'])
df_clean['ram'] = np.ceil(df_clean['ram'])
df_clean['weight'] = np.ceil(df_clean['weight'])
df_clean.drop(columns=['device_type', 'screen_size'], axis=1, inplace=True)
df_clean.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 3454 entries, 0 to 3453 Data columns (total 15 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 brand_name 3454 non-null object 1 os 3454 non-null object 2 4g 3454 non-null object 3 5g 3454 non-null object 4 main_camera_mp 3454 non-null float64 5 selfie_camera_mp 3454 non-null float64 6 int_memory 3454 non-null float64 7 ram 3454 non-null float64 8 battery 3454 non-null float64 9 weight 3454 non-null float64 10 release_year 3454 non-null int64 11 days_used 3454 non-null int64 12 normalized_used_price 3454 non-null float64 13 normalized_new_price 3454 non-null float64 14 screen_size_inches 3454 non-null float64 dtypes: float64(9), int64(2), object(4) memory usage: 404.9+ KB
df_clean.describe().T
| count | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|
| main_camera_mp | 3454.0 | 9.535611 | 4.659290 | 1.000000 | 5.000000 | 8.000000 | 13.000000 | 48.000000 |
| selfie_camera_mp | 3454.0 | 6.716850 | 6.842379 | 0.000000 | 2.000000 | 5.000000 | 8.000000 | 32.000000 |
| int_memory | 3454.0 | 54.528474 | 84.934991 | 0.010000 | 16.000000 | 32.000000 | 64.000000 | 1024.000000 |
| ram | 3454.0 | 4.062536 | 1.290937 | 1.000000 | 4.000000 | 4.000000 | 4.000000 | 12.000000 |
| battery | 3454.0 | 3132.577446 | 1298.884193 | 500.000000 | 2100.000000 | 3000.000000 | 4000.000000 | 9720.000000 |
| weight | 3454.0 | 182.698031 | 88.348437 | 69.000000 | 142.000000 | 160.000000 | 185.000000 | 855.000000 |
| release_year | 3454.0 | 2015.965258 | 2.298455 | 2013.000000 | 2014.000000 | 2015.500000 | 2018.000000 | 2020.000000 |
| days_used | 3454.0 | 674.869716 | 248.580166 | 91.000000 | 533.500000 | 690.500000 | 868.750000 | 1094.000000 |
| normalized_used_price | 3454.0 | 4.364712 | 0.588914 | 1.536867 | 4.033931 | 4.405133 | 4.755700 | 6.619433 |
| normalized_new_price | 3454.0 | 5.233107 | 0.683637 | 2.901422 | 4.790342 | 5.245892 | 5.673718 | 7.847841 |
| screen_size_inches | 3454.0 | 6.299942 | 1.479394 | 3.000000 | 6.000000 | 6.000000 | 7.000000 | 13.000000 |
- It is a good idea to explore the data once again after manipulating it.
num_cols = df_clean.select_dtypes(include=np.number).columns.tolist()
plt.figure(figsize=(12, 7))
sns.heatmap(df_clean[num_cols].corr(), annot=True, vmin=-1, vmax=1, fmt=".2f", cmap="Spectral")
plt.suptitle('Correlation heat map', fontsize=16)
plt.subplots_adjust(top=0.9)
plt.show()
for colname in ["normalized_new_price", "normalized_used_price"]:
plt.hist(df[colname], bins=20)
plt.title(colname)
Feature Engineering
- The goal is to predict used price.
-- Step 1 Encode categorical features -- Step 2 Split data into train (70%) and test data (30%) -- Step 3 Build a linear regression model and test performance.
d = df_clean.copy()
brand_mean_target = round(d.groupby('brand_name')['normalized_used_price'].mean(), 2)
d['brand_target_mean'] = d['brand_name'].map(brand_mean_target)
d.drop('brand_name', axis=1, inplace=True)
d.sample(20)
| os | 4g | 5g | main_camera_mp | selfie_camera_mp | int_memory | ram | battery | weight | release_year | days_used | normalized_used_price | normalized_new_price | screen_size_inches | brand_target_mean | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2448 | Android | yes | no | 13.0 | 5.0 | 16.0 | 4.0 | 3300.0 | 170.0 | 2016 | 581 | 4.129873 | 5.438601 | 6.0 | 4.47 |
| 828 | Others | yes | no | 8.0 | 2.0 | 16.0 | 4.0 | 2800.0 | 170.0 | 2015 | 1094 | 4.085472 | 4.385894 | 6.0 | 4.31 |
| 1970 | Android | yes | no | 8.0 | 5.0 | 16.0 | 4.0 | 3100.0 | 169.0 | 2018 | 660 | 3.956231 | 4.698296 | 6.0 | 4.43 |
| 1388 | Android | no | no | 1.0 | 1.0 | 256.0 | 1.0 | 1350.0 | 130.0 | 2013 | 575 | 3.028199 | 4.096675 | 4.0 | 4.65 |
| 135 | Android | yes | no | 13.0 | 8.0 | 32.0 | 3.0 | 4230.0 | 168.0 | 2018 | 664 | 4.457714 | 5.437253 | 7.0 | 4.76 |
| 3440 | Android | yes | no | 12.0 | 10.0 | 256.0 | 12.0 | 4300.0 | 196.0 | 2019 | 489 | 5.200153 | 6.509499 | 7.0 | 4.47 |
| 1106 | Android | yes | no | 13.0 | 8.0 | 32.0 | 4.0 | 3000.0 | 164.0 | 2018 | 641 | 4.480400 | 5.293104 | 6.0 | 4.67 |
| 2184 | Android | yes | no | 13.0 | 5.0 | 16.0 | 4.0 | 2410.0 | 140.0 | 2014 | 790 | 4.630545 | 5.677028 | 6.0 | 4.76 |
| 448 | Android | no | no | 2.0 | 1.0 | 16.0 | 4.0 | 3400.0 | 298.0 | 2014 | 920 | 4.094345 | 4.707546 | 8.0 | 4.22 |
| 1444 | Android | yes | no | 5.0 | 2.0 | 16.0 | 4.0 | 2500.0 | 169.0 | 2013 | 607 | 4.528613 | 5.590875 | 5.0 | 4.17 |
| 736 | Android | yes | no | 13.0 | 5.0 | 32.0 | 4.0 | 4100.0 | 159.0 | 2016 | 599 | 4.388257 | 5.337826 | 6.0 | 4.51 |
| 1593 | Android | no | no | 8.0 | 2.0 | 32.0 | 4.0 | 3000.0 | 172.0 | 2014 | 859 | 3.954508 | 5.196949 | 7.0 | 4.38 |
| 1774 | Android | yes | no | 5.0 | 2.0 | 16.0 | 4.0 | 2460.0 | 126.0 | 2013 | 910 | 4.236712 | 5.308466 | 5.0 | 4.30 |
| 310 | Android | yes | no | 8.0 | 16.0 | 32.0 | 3.0 | 4000.0 | 175.0 | 2019 | 482 | 4.486612 | 4.861903 | 7.0 | 4.30 |
| 2490 | Android | yes | no | 5.0 | 2.0 | 16.0 | 4.0 | 6000.0 | 490.0 | 2015 | 822 | 4.725084 | 5.832527 | 10.0 | 4.47 |
| 233 | Android | yes | no | 13.0 | 5.0 | 32.0 | 2.0 | 3020.0 | 146.0 | 2019 | 201 | 4.220096 | 4.496359 | 6.0 | 4.67 |
| 1281 | Android | yes | no | 13.0 | 8.0 | 128.0 | 4.0 | 3340.0 | 169.0 | 2018 | 496 | 4.815107 | 5.389802 | 7.0 | 4.65 |
| 1602 | Android | no | no | 8.0 | 2.0 | 16.0 | 4.0 | 9000.0 | 615.0 | 2014 | 954 | 4.824466 | 5.560566 | 11.0 | 4.38 |
| 2486 | Android | no | no | 5.0 | 2.0 | 32.0 | 4.0 | 2600.0 | 490.0 | 2015 | 556 | 4.729421 | 5.293807 | 10.0 | 4.47 |
| 367 | Android | yes | yes | 8.0 | 13.0 | 128.0 | 8.0 | 4000.0 | 169.0 | 2020 | 282 | 5.678499 | 6.780728 | 7.0 | 4.47 |
display(d.isna().sum())
X = d.drop('normalized_used_price', axis=1)
Y = d['normalized_used_price']
X = sm.add_constant(X)
X = pd.get_dummies(X, columns=X.select_dtypes(include=["object", "category"]).columns.tolist(), drop_first=True)
X = X.astype(float)
X.head()
os 0 4g 0 5g 0 main_camera_mp 0 selfie_camera_mp 0 int_memory 0 ram 0 battery 0 weight 0 release_year 0 days_used 0 normalized_used_price 0 normalized_new_price 0 screen_size_inches 0 brand_target_mean 0 dtype: int64
| const | main_camera_mp | selfie_camera_mp | int_memory | ram | battery | weight | release_year | days_used | normalized_new_price | screen_size_inches | brand_target_mean | os_Others | os_Windows | os_iOS | 4g_yes | 5g_yes | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1.0 | 13.0 | 5.0 | 64.0 | 3.0 | 3020.0 | 146.0 | 2020.0 | 127.0 | 4.715100 | 6.0 | 4.67 | 0.0 | 0.0 | 0.0 | 1.0 | 0.0 |
| 1 | 1.0 | 13.0 | 16.0 | 128.0 | 8.0 | 4300.0 | 213.0 | 2020.0 | 325.0 | 5.519018 | 7.0 | 4.67 | 0.0 | 0.0 | 0.0 | 1.0 | 1.0 |
| 2 | 1.0 | 13.0 | 8.0 | 128.0 | 8.0 | 4200.0 | 213.0 | 2020.0 | 162.0 | 5.884631 | 7.0 | 4.67 | 0.0 | 0.0 | 0.0 | 1.0 | 1.0 |
| 3 | 1.0 | 13.0 | 8.0 | 64.0 | 6.0 | 7250.0 | 480.0 | 2020.0 | 345.0 | 5.630961 | 11.0 | 4.67 | 0.0 | 0.0 | 0.0 | 1.0 | 1.0 |
| 4 | 1.0 | 13.0 | 8.0 | 64.0 | 3.0 | 5000.0 | 185.0 | 2020.0 | 293.0 | 4.947837 | 7.0 | 4.67 | 0.0 | 0.0 | 0.0 | 1.0 | 0.0 |
x_train, x_test, y_train, y_test = train_test_split(X, Y, test_size=0.3, random_state=1)
print("Number of rows in train data =", x_train.shape[0])
print("Number of rows in test data =", x_test.shape[0])
Number of rows in train data = 2417 Number of rows in test data = 1037
Model Building - Linear Regression¶
olsmodel = sm.OLS(y_train, x_train).fit()
display(olsmodel.summary())
| Dep. Variable: | normalized_used_price | R-squared: | 0.840 |
|---|---|---|---|
| Model: | OLS | Adj. R-squared: | 0.839 |
| Method: | Least Squares | F-statistic: | 785.0 |
| Date: | Sat, 13 Jul 2024 | Prob (F-statistic): | 0.00 |
| Time: | 01:24:00 | Log-Likelihood: | 83.201 |
| No. Observations: | 2417 | AIC: | -132.4 |
| Df Residuals: | 2400 | BIC: | -33.97 |
| Df Model: | 16 | ||
| Covariance Type: | nonrobust |
| coef | std err | t | P>|t| | [0.025 | 0.975] | |
|---|---|---|---|---|---|---|
| const | -57.6243 | 8.939 | -6.446 | 0.000 | -75.153 | -40.096 |
| main_camera_mp | 0.0206 | 0.001 | 14.409 | 0.000 | 0.018 | 0.023 |
| selfie_camera_mp | 0.0136 | 0.001 | 12.424 | 0.000 | 0.011 | 0.016 |
| int_memory | 8.175e-05 | 6.74e-05 | 1.213 | 0.225 | -5.04e-05 | 0.000 |
| ram | 0.0215 | 0.005 | 4.022 | 0.000 | 0.011 | 0.032 |
| battery | -9.406e-06 | 7.13e-06 | -1.320 | 0.187 | -2.34e-05 | 4.57e-06 |
| weight | 0.0010 | 0.000 | 7.265 | 0.000 | 0.001 | 0.001 |
| release_year | 0.0291 | 0.004 | 6.579 | 0.000 | 0.020 | 0.038 |
| days_used | 3.727e-05 | 3.07e-05 | 1.214 | 0.225 | -2.29e-05 | 9.75e-05 |
| normalized_new_price | 0.4213 | 0.012 | 36.329 | 0.000 | 0.399 | 0.444 |
| screen_size_inches | 0.0591 | 0.008 | 7.085 | 0.000 | 0.043 | 0.075 |
| brand_target_mean | 0.0202 | 0.020 | 1.012 | 0.312 | -0.019 | 0.059 |
| os_Others | -0.0695 | 0.029 | -2.387 | 0.017 | -0.127 | -0.012 |
| os_Windows | 0.0315 | 0.037 | 0.860 | 0.390 | -0.040 | 0.103 |
| os_iOS | -0.0778 | 0.046 | -1.692 | 0.091 | -0.168 | 0.012 |
| 4g_yes | 0.0463 | 0.015 | 2.984 | 0.003 | 0.016 | 0.077 |
| 5g_yes | -0.0051 | 0.032 | -0.160 | 0.873 | -0.068 | 0.057 |
| Omnibus: | 229.420 | Durbin-Watson: | 1.904 |
|---|---|---|---|
| Prob(Omnibus): | 0.000 | Jarque-Bera (JB): | 432.474 |
| Skew: | -0.634 | Prob(JB): | 1.23e-94 |
| Kurtosis: | 4.640 | Cond. No. | 7.40e+06 |
Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
[2] The condition number is large, 7.4e+06. This might indicate that there are
strong multicollinearity or other numerical problems.
Observations:¶
- The coefficient for the intercept (const) represents the expected value of the dependent variable (used device price) when all the predictor variables are set to zero.
- The predictor coefs represents the average change in the dependent variable for a one unit change in the predictor holding all other variables constant.
- The std err (standard error) reflects the level of accuracy of the coefficients.
- The lower the std err the higher the level of accuracy.
- For each independent feature there is a p-value (probability value) that represents the significance level of the coeffecient.
- $H_o$ : Independent feature is not significant ($\beta_i = 0$)
- $H_a$ : Independent feature is that it is significant ($\beta_i \neq 0$)
- Any p-value less than 0.05 is considered statistically significant, meaning we can reject the null hypothesis and that there is significant evidence to show that the feature does have an impact on prediction of the used device price.
- The confidence interval (last two columns in coefficient rows) represents the range in which the coefficients are likley to fall (with 95% probability)
Model Performance Check¶
def adj_r2_score(predictors, targets, predictions):
r2 = r2_score(targets, predictions)
n = predictors.shape[0]
k = predictors.shape[1]
return 1 - ((1 - r2) * (n - 1) / (n - k - 1))
def mape_score(targets, predictions):
return np.mean(np.abs(targets - predictions) / targets) * 100
def model_performance_regression(model, predictors, target):
"""
Function to compute different metrics to check regression model performance
model: regressor
predictors: independent variables
target: dependent variable
"""
pred = model.predict(predictors)
r2 = r2_score(target, pred)
adjr2 = adj_r2_score(predictors, target, pred)
rmse = np.sqrt(mean_squared_error(target, pred))
mae = mean_absolute_error(target, pred)
mape = mape_score(target, pred)
df_perf = pd.DataFrame(
{
"RMSE": rmse,
"MAE": mae,
"R-squared": r2,
"Adj. R-squared": adjr2,
"MAPE": mape,
},
index=[0],
)
return df_perf
olsmodel_train_perf = model_performance_regression(olsmodel, x_train, y_train)
olsmodel_test_perf = model_performance_regression(olsmodel, x_test, y_test)
print("Training Performance")
display(olsmodel_train_perf)
print("Test Performance")
display(olsmodel_test_perf)
Training Performance
| RMSE | MAE | R-squared | Adj. R-squared | MAPE | |
|---|---|---|---|---|---|
| 0 | 0.233783 | 0.183622 | 0.839579 | 0.838443 | 4.411459 |
Test Performance
| RMSE | MAE | R-squared | Adj. R-squared | MAPE | |
|---|---|---|---|---|---|
| 0 | 0.23797 | 0.183983 | 0.842991 | 0.840372 | 4.49641 |
Observations:
- The training adjusted R-squared is 0.84. The model is not underfitting.
- The train and test RMSE (root mean squared error) and the MAE (mean absolute error) are comparable. The model is not overfitting.
- MAE suggest the model can predict the used device price within a mean error of 0.18 on the test data.
- MAPE (mean absolute percentage error) of 4.5 on test data means that the model's predictions are, on average, within 4.5% of the actual used device price. This shows the model is very accurate in forcasting the price of used devices.
Checking Linear Regression Assumptions¶
In order to make statistical inferences from a linear regression model, it is important to ensure that the assumptions of linear regression are satisfied.
We will be checking the following Linear Regression assumptions:
No Multicollinearity
Linearity of variables
Independence of error terms
Normality of error terms
No Heteroscedasticity
TEST FOR MULTICOLLINEARITY¶
from statsmodels.stats.outliers_influence import variance_inflation_factor
def checking_vif(predictors):
vif = pd.DataFrame()
vif["feature"] = predictors.columns
vif["VIF"] = [variance_inflation_factor(predictors.values, i) for i in range(len(predictors.columns))]
return vif
vif = checking_vif(x_train)
vif
| feature | VIF | |
|---|---|---|
| 0 | const | 3.508742e+06 |
| 1 | main_camera_mp | 1.947758e+00 |
| 2 | selfie_camera_mp | 2.519080e+00 |
| 3 | int_memory | 1.247329e+00 |
| 4 | ram | 2.144870e+00 |
| 5 | battery | 3.838267e+00 |
| 6 | weight | 6.101573e+00 |
| 7 | release_year | 4.536312e+00 |
| 8 | days_used | 2.579905e+00 |
| 9 | normalized_new_price | 2.733247e+00 |
| 10 | screen_size_inches | 6.859834e+00 |
| 11 | brand_target_mean | 1.660658e+00 |
| 12 | os_Others | 1.434433e+00 |
| 13 | os_Windows | 1.027725e+00 |
| 14 | os_iOS | 1.138248e+00 |
| 15 | 4g_yes | 2.308747e+00 |
| 16 | 5g_yes | 1.822183e+00 |
def treating_multicollinearity(predictors, target, high_vif_columns):
"""
Checking the effect of dropping the columns showing high multicollinearity
on model performance (adj. R-squared and RMSE)
predictors: independent variables
target: dependent variable
high_vif_columns: columns having high VIF
"""
adj_r2 = []
rmse = []
for cols in high_vif_columns:
train = predictors.loc[:, ~predictors.columns.str.startswith(cols)]
olsmodel = sm.OLS(target, train).fit()
adj_r2.append(olsmodel.rsquared_adj)
rmse.append(np.sqrt(olsmodel.mse_resid))
temp = pd.DataFrame(
{
"col": high_vif_columns,
"adj_r2": adj_r2,
"rmse_after": rmse,
}
).sort_values(by="adj_r2", ascending=True)
temp.reset_index(drop=True, inplace=True)
return temp
Removing Multicollinearity¶
To remove multicollinearity
- Drop every column one by one that has a VIF score greater than 5.
- Look at the adjusted R-squared and RMSE of all these models.
- Drop the variable that makes the least change in adjusted R-squared.
- Check the VIF scores again.
- Continue till you get all VIF scores under 5.
col_list = vif[(vif['VIF'] > 5) & (vif['feature'] != 'const')]['feature'].head(2).tolist()
res = treating_multicollinearity(x_train, y_train, col_list)
display(res)
| col | adj_r2 | rmse_after | |
|---|---|---|---|
| 0 | weight | 0.835027 | 0.237126 |
| 1 | screen_size_inches | 0.835201 | 0.237001 |
col_to_drop = res["col"][0]
x_train2 = x_train.loc[:, ~x_train.columns.str.startswith(col_to_drop)]
x_test2 = x_test.loc[:, ~x_test.columns.str.startswith(col_to_drop)]
vif = checking_vif(x_train2)
print("VIF after dropping:", col_to_drop)
display(vif)
VIF after dropping: weight
| feature | VIF | |
|---|---|---|
| 0 | const | 3.401353e+06 |
| 1 | main_camera_mp | 1.866595e+00 |
| 2 | selfie_camera_mp | 2.496045e+00 |
| 3 | int_memory | 1.247326e+00 |
| 4 | ram | 2.141976e+00 |
| 5 | battery | 3.431984e+00 |
| 6 | release_year | 4.394034e+00 |
| 7 | days_used | 2.572847e+00 |
| 8 | normalized_new_price | 2.725345e+00 |
| 9 | screen_size_inches | 3.214652e+00 |
| 10 | brand_target_mean | 1.658241e+00 |
| 11 | os_Others | 1.320344e+00 |
| 12 | os_Windows | 1.027209e+00 |
| 13 | os_iOS | 1.129024e+00 |
| 14 | 4g_yes | 2.286757e+00 |
| 15 | 5g_yes | 1.819929e+00 |
Observations:¶
- All VIF values are less than 5.
- There are no more multicollinearity in the data.
olsmod1 = sm.OLS(y_train, x_train2).fit()
display(olsmod1.summary())
| Dep. Variable: | normalized_used_price | R-squared: | 0.836 |
|---|---|---|---|
| Model: | OLS | Adj. R-squared: | 0.835 |
| Method: | Least Squares | F-statistic: | 816.3 |
| Date: | Sat, 13 Jul 2024 | Prob (F-statistic): | 0.00 |
| Time: | 01:24:01 | Log-Likelihood: | 56.912 |
| No. Observations: | 2417 | AIC: | -81.82 |
| Df Residuals: | 2401 | BIC: | 10.82 |
| Df Model: | 15 | ||
| Covariance Type: | nonrobust |
| coef | std err | t | P>|t| | [0.025 | 0.975] | |
|---|---|---|---|---|---|---|
| const | -46.2631 | 8.895 | -5.201 | 0.000 | -63.707 | -28.820 |
| main_camera_mp | 0.0185 | 0.001 | 13.064 | 0.000 | 0.016 | 0.021 |
| selfie_camera_mp | 0.0128 | 0.001 | 11.658 | 0.000 | 0.011 | 0.015 |
| int_memory | 8.26e-05 | 6.81e-05 | 1.213 | 0.225 | -5.09e-05 | 0.000 |
| ram | 0.0201 | 0.005 | 3.718 | 0.000 | 0.010 | 0.031 |
| battery | 7.437e-06 | 6.81e-06 | 1.092 | 0.275 | -5.92e-06 | 2.08e-05 |
| release_year | 0.0234 | 0.004 | 5.320 | 0.000 | 0.015 | 0.032 |
| days_used | 4.894e-05 | 3.1e-05 | 1.579 | 0.114 | -1.18e-05 | 0.000 |
| normalized_new_price | 0.4259 | 0.012 | 36.383 | 0.000 | 0.403 | 0.449 |
| screen_size_inches | 0.1033 | 0.006 | 17.894 | 0.000 | 0.092 | 0.115 |
| brand_target_mean | 0.0146 | 0.020 | 0.727 | 0.467 | -0.025 | 0.054 |
| os_Others | -0.0099 | 0.028 | -0.349 | 0.727 | -0.065 | 0.046 |
| os_Windows | 0.0374 | 0.037 | 1.012 | 0.311 | -0.035 | 0.110 |
| os_iOS | -0.0477 | 0.046 | -1.031 | 0.303 | -0.139 | 0.043 |
| 4g_yes | 0.0353 | 0.016 | 2.262 | 0.024 | 0.005 | 0.066 |
| 5g_yes | 0.0030 | 0.032 | 0.095 | 0.925 | -0.060 | 0.066 |
| Omnibus: | 216.522 | Durbin-Watson: | 1.909 |
|---|---|---|---|
| Prob(Omnibus): | 0.000 | Jarque-Bera (JB): | 393.139 |
| Skew: | -0.616 | Prob(JB): | 4.27e-86 |
| Kurtosis: | 4.545 | Cond. No. | 7.28e+06 |
Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
[2] The condition number is large, 7.28e+06. This might indicate that there are
strong multicollinearity or other numerical problems.
Observations:¶
- After removing weight we can see that const has changed and the std err value is slightly lower.
- The confidence interval is also slightly smaller which indicates improved accuracy.
- before: const -57.6243 8.939 -6.446 0.000 -75.153 -40.096
- after : const -46.2631 8.895 -5.201 0.000 -63.707 -28.820
- before: R-squared: 0.840
- after : R-squared: 0.836
- The adjusted R-squared value has dropped only a small amount so dropping the column did not have much impact.
- before: Adj. R-squared: 0.839
- after : Adj. R-squared: 0.835
Dealing with high p-value variables¶
- Some of the dummy variables in the data have p-value > 0.05. Variables that have a value greater than 0.5 are considered insignificant and should be dropped.
- p-values may change after dropping a variable. We will drop one at a time starting with the highest p-value.
- Then we will create a new model without the dropped feature and check p-values again repeating the process till there are no insignificant features in our model.
def remove_insignificant_features(predictors):
selected_features = predictors.columns.tolist()
removed_features = []
while len(selected_features) > 0:
x_train_aux = predictors[selected_features]
model = sm.OLS(y_train, x_train_aux).fit()
p_values = model.pvalues
max_p_value = max(p_values)
feature_with_p_max = p_values.idxmax()
if max_p_value > 0.05:
selected_features.remove(feature_with_p_max)
removed_features.append(feature_with_p_max)
else:
break
print('Removed features:', removed_features)
print('Remaining features: ', selected_features)
return selected_features
selected_features = remove_insignificant_features(x_train2.copy())
Removed features: ['5g_yes', 'os_Others', 'brand_target_mean', 'os_iOS', 'os_Windows', 'battery', 'int_memory', 'days_used'] Remaining features: ['const', 'main_camera_mp', 'selfie_camera_mp', 'ram', 'release_year', 'normalized_new_price', 'screen_size_inches', '4g_yes']
x_train3 = x_train2[selected_features]
x_test3 = x_test2[selected_features]
olsmod2 = sm.OLS(y_train, x_train3).fit()
display(olsmod2.summary())
| Dep. Variable: | normalized_used_price | R-squared: | 0.836 |
|---|---|---|---|
| Model: | OLS | Adj. R-squared: | 0.835 |
| Method: | Least Squares | F-statistic: | 1749. |
| Date: | Sat, 13 Jul 2024 | Prob (F-statistic): | 0.00 |
| Time: | 01:24:01 | Log-Likelihood: | 53.286 |
| No. Observations: | 2417 | AIC: | -90.57 |
| Df Residuals: | 2409 | BIC: | -44.25 |
| Df Model: | 7 | ||
| Covariance Type: | nonrobust |
| coef | std err | t | P>|t| | [0.025 | 0.975] | |
|---|---|---|---|---|---|---|
| const | -40.5048 | 6.806 | -5.951 | 0.000 | -53.851 | -27.159 |
| main_camera_mp | 0.0188 | 0.001 | 14.060 | 0.000 | 0.016 | 0.021 |
| selfie_camera_mp | 0.0130 | 0.001 | 12.139 | 0.000 | 0.011 | 0.015 |
| ram | 0.0210 | 0.005 | 4.510 | 0.000 | 0.012 | 0.030 |
| release_year | 0.0206 | 0.003 | 6.107 | 0.000 | 0.014 | 0.027 |
| normalized_new_price | 0.4285 | 0.011 | 39.377 | 0.000 | 0.407 | 0.450 |
| screen_size_inches | 0.1076 | 0.004 | 28.380 | 0.000 | 0.100 | 0.115 |
| 4g_yes | 0.0385 | 0.015 | 2.555 | 0.011 | 0.009 | 0.068 |
| Omnibus: | 216.248 | Durbin-Watson: | 1.911 |
|---|---|---|---|
| Prob(Omnibus): | 0.000 | Jarque-Bera (JB): | 396.969 |
| Skew: | -0.612 | Prob(JB): | 6.30e-87 |
| Kurtosis: | 4.563 | Cond. No. | 2.85e+06 |
Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
[2] The condition number is large, 2.85e+06. This might indicate that there are
strong multicollinearity or other numerical problems.
olsmod2_train_perf = model_performance_regression(olsmod2, x_train3, y_train)
olsmod2_test_perf = model_performance_regression(olsmod2, x_test3, y_test)
print("Training Performance")
display(olsmod2_train_perf)
print("Test Performance")
display(olsmod2_test_perf)
Training Performance
| RMSE | MAE | R-squared | Adj. R-squared | MAPE | |
|---|---|---|---|---|---|
| 0 | 0.236695 | 0.185567 | 0.835559 | 0.835012 | 4.452555 |
Test Performance
| RMSE | MAE | R-squared | Adj. R-squared | MAPE | |
|---|---|---|---|---|---|
| 0 | 0.240362 | 0.185432 | 0.839819 | 0.838572 | 4.526207 |
Observations after removing features with high p-values¶
- There are no insignificant features in the model
- The adjusted R-Squared is 0.836 meaning the model can explain up to 84% of the variance in the used price.
- The adjusted R-Squared in olsmod1 (after removing variables with multicollineariety) was 0.835. This very small difference indicates the variables that were dropped were not impacting the model.
- RMSE and MAE values are very close for the training and testing data sets, indicating the model is not overfitting.
TEST FOR LINEARITY AND INDEPENDENCE¶
- Linearity describes a straight line relationship between two variables. Predcitor variables must have a linear relationship with the dependent variable
- If the error terms (or residuals) are not independence then the confidence intervals of the coefficeint estimates will be narrower and could cause the conclustion that a parameter is statistically significant when it is not.
df_pred = pd.DataFrame()
df_pred["Actual Values"] = y_train
df_pred["Fitted Values"] = olsmod2.fittedvalues
df_pred["Residuals"] = olsmod2.resid
df_pred.head()
| Actual Values | Fitted Values | Residuals | |
|---|---|---|---|
| 3026 | 4.087488 | 3.867893 | 0.219594 |
| 1525 | 4.448399 | 4.582682 | -0.134283 |
| 1128 | 4.315353 | 4.288759 | 0.026593 |
| 3003 | 4.282068 | 4.255228 | 0.026840 |
| 2907 | 4.456438 | 4.459759 | -0.003321 |
plt.figure(figsize=(5, 5))
sns.residplot(data=df_pred, x="Fitted Values", y="Residuals", color="purple", lowess=True)
plt.xlabel("Fitted Values")
plt.ylabel("Residuals")
plt.title("Fitted vs Residual plot")
plt.show()
Observation:¶
- If there is a pattern in the plot between fitted and residuals it would indicate non-linearity in the data which would mean the model is not capturing the non-linear effects.
- In this plot there is no pattern indicating that the assumptions of linearity and independence are met.
TEST FOR NORMALITY¶
- Error terms or residuals should be normally distributed. If they are not, confidence intevals may be too narrow and inaccurate, making coefficient estimation more difficult. Non-normality suggests outliers need to be checked to improve the model.
plt.figure(figsize=(5, 5))
sns.histplot(data=df_pred, x="Residuals", kde=True)
plt.title("Normality of residuals")
plt.show()
plt.figure(figsize=(5, 5))
stats.probplot(df_pred["Residuals"], dist="norm", plot=pylab)
plt.show()
stats.shapiro(df_pred["Residuals"])
ShapiroResult(statistic=0.9717327952384949, pvalue=1.93208428849325e-21)
Observations for Normality¶
- The histogram of residuals has a bell shape characteristic of normality but it does look a little left skewed.
- The residuals mostly follow a straight line in the q-q plot.
- The p-value in the Shapiro test is very small which indicates that the residuals may not be normal and this could lead to issues such as unreliable confidence intervals and biased coefficient estimates in the model.
- Based on the histogram and q-q plot we will consider the assumption of normality to be met.
TEST FOR HOMOSCEDASTICITY¶
- If the variance of the residuals is symmetrically distributed across the regression line, then the data is said to be homoscedastic.
- If the variance is unequal for the residuals across the regression line, then the data is said to be heteroscedastic.
- The presence of non-constant variance in the error terms results in heteroscedasticity. Generally, non-constant variance arises in presence of outliers.
name = ["F statistic", "p-value"]
test = sms.het_goldfeldquandt(df_pred["Residuals"], x_train3)
lzip(name, test)
[('F statistic', 1.0520632950072462), ('p-value', 0.18969074531364338)]
Observation on Test for Homoscedasticity¶
- The p-value is greater than 0.05 so the residuals are homoscedastic and the assumption is met.
# predictions on the test set
pred = olsmod2.predict(x_test3)
df_pred_test = pd.DataFrame({"Actual": y_test, "Predicted": pred})
df_pred_test.sample(10, random_state=1)
| Actual | Predicted | |
|---|---|---|
| 1995 | 4.566741 | 4.373350 |
| 2341 | 3.696103 | 3.942322 |
| 1913 | 3.592093 | 3.774153 |
| 688 | 4.306495 | 4.120242 |
| 650 | 4.522115 | 5.133677 |
| 2291 | 4.259294 | 4.399627 |
| 40 | 4.997685 | 5.444454 |
| 1884 | 3.875359 | 4.101330 |
| 2538 | 4.206631 | 4.066285 |
| 45 | 5.380450 | 5.276652 |
Final Model¶
x_train_final = x_train3.copy()
x_test_final = x_test3.copy()
olsmodel_final = sm.OLS(y_train, x_train_final).fit()
display(olsmodel_final.summary())
| Dep. Variable: | normalized_used_price | R-squared: | 0.836 |
|---|---|---|---|
| Model: | OLS | Adj. R-squared: | 0.835 |
| Method: | Least Squares | F-statistic: | 1749. |
| Date: | Sat, 13 Jul 2024 | Prob (F-statistic): | 0.00 |
| Time: | 01:24:01 | Log-Likelihood: | 53.286 |
| No. Observations: | 2417 | AIC: | -90.57 |
| Df Residuals: | 2409 | BIC: | -44.25 |
| Df Model: | 7 | ||
| Covariance Type: | nonrobust |
| coef | std err | t | P>|t| | [0.025 | 0.975] | |
|---|---|---|---|---|---|---|
| const | -40.5048 | 6.806 | -5.951 | 0.000 | -53.851 | -27.159 |
| main_camera_mp | 0.0188 | 0.001 | 14.060 | 0.000 | 0.016 | 0.021 |
| selfie_camera_mp | 0.0130 | 0.001 | 12.139 | 0.000 | 0.011 | 0.015 |
| ram | 0.0210 | 0.005 | 4.510 | 0.000 | 0.012 | 0.030 |
| release_year | 0.0206 | 0.003 | 6.107 | 0.000 | 0.014 | 0.027 |
| normalized_new_price | 0.4285 | 0.011 | 39.377 | 0.000 | 0.407 | 0.450 |
| screen_size_inches | 0.1076 | 0.004 | 28.380 | 0.000 | 0.100 | 0.115 |
| 4g_yes | 0.0385 | 0.015 | 2.555 | 0.011 | 0.009 | 0.068 |
| Omnibus: | 216.248 | Durbin-Watson: | 1.911 |
|---|---|---|---|
| Prob(Omnibus): | 0.000 | Jarque-Bera (JB): | 396.969 |
| Skew: | -0.612 | Prob(JB): | 6.30e-87 |
| Kurtosis: | 4.563 | Cond. No. | 2.85e+06 |
Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
[2] The condition number is large, 2.85e+06. This might indicate that there are
strong multicollinearity or other numerical problems.
olsmodel_final_train_perf = model_performance_regression(olsmodel_final, x_train_final, y_train)
olsmodel_final_test_perf = model_performance_regression(olsmodel_final, x_test_final, y_test)
print("Training Performance")
display(olsmodel_final_train_perf)
print("Test Performance")
display(olsmodel_final_test_perf)
Training Performance
| RMSE | MAE | R-squared | Adj. R-squared | MAPE | |
|---|---|---|---|---|---|
| 0 | 0.236695 | 0.185567 | 0.835559 | 0.835012 | 4.452555 |
Test Performance
| RMSE | MAE | R-squared | Adj. R-squared | MAPE | |
|---|---|---|---|---|---|
| 0 | 0.240362 | 0.185432 | 0.839819 | 0.838572 | 4.526207 |
Actionable Insights and Recommendations¶
Model Performance
- High R-Squared value of 0.836) indicates that 83.6% of the variance in the used data prices can be explained by this model. The high number indicates it is a strong relationship between the model features and the price making it a good predictor.
- The low RMSE and MAE on both training and test data suggest the model's predictions are accurate and reliable.
Significant Features
- Main Camera Megapixes, Selfie Camera Megapixels, Ram, Release Year, New Price and Screen Size are all contributing positively to the used price.
- The higher the values for the significant features the higher the used device price.
Insignificant Features
- Battering, internal memory and days used all have a higher p-values and were removed as they were deemed insignificant
Recomendations¶
- Use significant features identified to set competitive prices for used devices. Focus on the main and selfie camera resolutions, ram and release year.
- Highlight the significant features in the product listings and marketing materials to attract buyers.
- Prioritize bringing in and selling new models with higher initial prices, larger screens and advance cameras.
- Continuously monitor model performance and update when new data is available to ensure accuracy.