Background

My friend is interested in acquiring an apartment building in particular locations like Humboldt Park, Lincoln Park, Logan Square, and West Town areas. I want to help by predicting prices, so I can tell him if properties are good deals or not. He gave me some information with 2-4 Unit and 5+ Unit apartment buildings in areas he wants to acquire. The given dataset has 27 variables and 125 observations.

Data Preparation/Cleaning

In [1]:
import seaborn as sns
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
%matplotlib inline
In [2]:
df = pd.read_csv('properties.csv')
In [3]:
df.head()
Out[3]:
SALE TYPE SOLD DATE PROPERTY TYPE ADDRESS CITY STATE ZIP PRICE BEDS BATHS ... STATUS NEXT OPEN HOUSE START TIME NEXT OPEN HOUSE END TIME URL SOURCE MLS# FAVORITE INTERESTED LATITUDE LONGITUDE
0 MLS Listing NaN Multi-Family (5+ Unit) 1843 W Thomas St CHICAGO IL 60622 1699000 37 24.0 ... Active NaN NaN http://www.redfin.com/IL/Chicago/1843-W-Thomas... MRED 10133801 N Y 41.901249 -87.674025
1 MLS Listing NaN Multi-Family (5+ Unit) 2027 N SHEFFIELD Ave CHICAGO IL 60614 1495000 27 21.0 ... Active NaN NaN http://www.redfin.com/IL/Chicago/2027-N-Sheffi... MRED 10135551 N Y 41.918939 -87.653123
2 MLS Listing NaN Multi-Family (2-4 Unit) 834 N Mozart St CHICAGO IL 60622 449900 8 4.0 ... Active NaN NaN http://www.redfin.com/IL/Chicago/834-N-Mozart-... MRED 10139661 N Y 41.896717 -87.698266
3 MLS Listing NaN Multi-Family (2-4 Unit) 867 N Richmond St CHICAGO IL 60622 418900 7 2.0 ... Active NaN NaN http://www.redfin.com/IL/Chicago/867-N-Richmon... MRED 10116300 N Y 41.897491 -87.700153
4 MLS Listing NaN Multi-Family (2-4 Unit) 1753 W Superior St CHICAGO IL 60622 549000 8 3.0 ... Active NaN NaN http://www.redfin.com/IL/Chicago/1753-W-Superi... MRED 10123659 N Y 41.894910 -87.671813

5 rows × 27 columns

In [4]:
df.shape
Out[4]:
(125, 27)

We can see from the upcoming heatmap that some of the columns entirely lost. We can drop them; we also may toss out columns that only have text information; they will be hard to interpret for our future model.

In [5]:
sns.heatmap(df.isnull(),yticklabels=False,cbar=False,cmap='viridis')
Out[5]:
<matplotlib.axes._subplots.AxesSubplot at 0x1a0e2274e0>
In [6]:
df.columns
Out[6]:
Index(['SALE TYPE', 'SOLD DATE', 'PROPERTY TYPE', 'ADDRESS', 'CITY', 'STATE',
       'ZIP', 'PRICE', 'BEDS', 'BATHS', 'LOCATION', 'SQUARE FEET', 'LOT SIZE',
       'YEAR BUILT', 'DAYS ON MARKET', '$/SQUARE FEET', 'HOA/MONTH', 'STATUS',
       'NEXT OPEN HOUSE START TIME', 'NEXT OPEN HOUSE END TIME', 'URL',
       'SOURCE', 'MLS#', 'FAVORITE', 'INTERESTED', 'LATITUDE', 'LONGITUDE'],
      dtype='object')
In [7]:
df.drop(['SOLD DATE','SQUARE FEET','$/SQUARE FEET','HOA/MONTH',
        'NEXT OPEN HOUSE END TIME','NEXT OPEN HOUSE END TIME',
        'URL','SOURCE', 'MLS#', 'LATITUDE', 'LONGITUDE', 'FAVORITE',
             'STATUS','NEXT OPEN HOUSE START TIME','INTERESTED',
             'ADDRESS','CITY', 'STATE','ZIP'], axis=1, inplace=True)
In [8]:
df.head()
Out[8]:
SALE TYPE PROPERTY TYPE PRICE BEDS BATHS LOCATION LOT SIZE YEAR BUILT DAYS ON MARKET
0 MLS Listing Multi-Family (5+ Unit) 1699000 37 24.0 West Town 2613.0 1889.0 13
1 MLS Listing Multi-Family (5+ Unit) 1495000 27 21.0 Lincoln Park NaN 1888.0 10
2 MLS Listing Multi-Family (2-4 Unit) 449900 8 4.0 West Town 3000.0 1883.0 6
3 MLS Listing Multi-Family (2-4 Unit) 418900 7 2.0 West Town 3484.0 1905.0 35
4 MLS Listing Multi-Family (2-4 Unit) 549000 8 3.0 West Town 2613.0 1905.0 26
In [9]:
df.shape
Out[9]:
(125, 9)

For the sake of readability when we will be doing our further analysis, I renamed all the columns.

In [10]:
df = df.rename(columns={'SALE TYPE':'SALE_TYPE','PROPERTY TYPE':'PROPERTY_TYPE','LOT SIZE':'LOT_SIZE',
                   'YEAR BUILT':'YEAR_BUILT','DAYS ON MARKET':'DAYS_ON_MARKET'})
In [11]:
df.head()
Out[11]:
SALE_TYPE PROPERTY_TYPE PRICE BEDS BATHS LOCATION LOT_SIZE YEAR_BUILT DAYS_ON_MARKET
0 MLS Listing Multi-Family (5+ Unit) 1699000 37 24.0 West Town 2613.0 1889.0 13
1 MLS Listing Multi-Family (5+ Unit) 1495000 27 21.0 Lincoln Park NaN 1888.0 10
2 MLS Listing Multi-Family (2-4 Unit) 449900 8 4.0 West Town 3000.0 1883.0 6
3 MLS Listing Multi-Family (2-4 Unit) 418900 7 2.0 West Town 3484.0 1905.0 35
4 MLS Listing Multi-Family (2-4 Unit) 549000 8 3.0 West Town 2613.0 1905.0 26
In [12]:
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 125 entries, 0 to 124
Data columns (total 9 columns):
SALE_TYPE         125 non-null object
PROPERTY_TYPE     125 non-null object
PRICE             125 non-null int64
BEDS              125 non-null int64
BATHS             125 non-null float64
LOCATION          125 non-null object
LOT_SIZE          120 non-null float64
YEAR_BUILT        109 non-null float64
DAYS_ON_MARKET    125 non-null int64
dtypes: float64(3), int64(3), object(3)
memory usage: 8.9+ KB
In [13]:
df.columns
Out[13]:
Index(['SALE_TYPE', 'PROPERTY_TYPE', 'PRICE', 'BEDS', 'BATHS', 'LOCATION',
       'LOT_SIZE', 'YEAR_BUILT', 'DAYS_ON_MARKET'],
      dtype='object')

We need to prepare our dataset for the machine learning process. For that, first I decided to turn our object values into dummies.

In [14]:
for column in ['SALE_TYPE', 'PROPERTY_TYPE', 'LOCATION']:
    print(df[column].unique())
    dummies = pd.get_dummies(df[column],prefix=column).iloc[:,1:]
    df = pd.concat([df, dummies],axis=1)
['MLS Listing' 'For-Sale-by-Owner Listing']
['Multi-Family (5+ Unit)' 'Multi-Family (2-4 Unit)']
['West Town' 'Lincoln Park' 'Logan Square' 'Humboldt Park' 'Chicago']

Since we will be using only integers in our machine learning process, we can get rid of the columns we turned into dummies. In our case, we will drop three columns.

In [15]:
df.drop(['SALE_TYPE', 'PROPERTY_TYPE', 'LOCATION'], axis=1, inplace=True)

We also notice that the LOT_SIZE and YEAR_BUILT columns have some missing values and we need to fill them.

In [16]:
df.isnull().sum()
Out[16]:
PRICE                                    0
BEDS                                     0
BATHS                                    0
LOT_SIZE                                 5
YEAR_BUILT                              16
DAYS_ON_MARKET                           0
SALE_TYPE_MLS Listing                    0
PROPERTY_TYPE_Multi-Family (5+ Unit)     0
LOCATION_Humboldt Park                   0
LOCATION_Lincoln Park                    0
LOCATION_Logan Square                    0
LOCATION_West Town                       0
dtype: int64

If I drop the rows with missing values, I might end up losing valuable data. Since we don't have abundant observations, it would be better to try to keep as much data as we can.

In [17]:
df[['YEAR_BUILT', 'LOT_SIZE']].median()
Out[17]:
YEAR_BUILT    1892.0
LOT_SIZE      3049.0
dtype: float64
In [18]:
df[['YEAR_BUILT', 'LOT_SIZE']]= df[['YEAR_BUILT', 'LOT_SIZE']].fillna(df[['YEAR_BUILT', 'LOT_SIZE']].median())
In [19]:
sns.heatmap(df.isnull(),yticklabels=False,cbar=False,cmap='viridis')
df.shape
Out[19]:
(125, 12)

Now we left with clean dataset for our future exploration.

In [20]:
df.head()
Out[20]:
PRICE BEDS BATHS LOT_SIZE YEAR_BUILT DAYS_ON_MARKET SALE_TYPE_MLS Listing PROPERTY_TYPE_Multi-Family (5+ Unit) LOCATION_Humboldt Park LOCATION_Lincoln Park LOCATION_Logan Square LOCATION_West Town
0 1699000 37 24.0 2613.0 1889.0 13 1 1 0 0 0 1
1 1495000 27 21.0 3049.0 1888.0 10 1 1 0 1 0 0
2 449900 8 4.0 3000.0 1883.0 6 1 0 0 0 0 1
3 418900 7 2.0 3484.0 1905.0 35 1 0 0 0 0 1
4 549000 8 3.0 2613.0 1905.0 26 1 0 0 0 0 1

Data Visualization

We see from the next plot that 'BEDS', 'BATHS' variables are strongly correlated with PRICE.

In [21]:
sns.pairplot(df, x_vars=['BEDS', 'BATHS'], y_vars='PRICE', size=7, aspect=0.7, kind='reg')
Out[21]:
<seaborn.axisgrid.PairGrid at 0x1a1703a3c8>
In [22]:
sns.pairplot(df, x_vars=['YEAR_BUILT'], y_vars='PRICE', size=7, aspect=0.7, kind='reg')
Out[22]:
<seaborn.axisgrid.PairGrid at 0x1a17401a58>

This seems a little odd, and normally I would check with my friend to see if this data is incomplete. But for the sake of the exercise, I won't dig into it.

When we wanted to assess the correlation between LOT_SIZE and PRICE we see something strange happens. Let's find out.

In [23]:
sns.pairplot(df, x_vars=['LOT_SIZE'], y_vars='PRICE', size=7, aspect=0.7, kind='reg')
Out[23]:
<seaborn.axisgrid.PairGrid at 0x1a174019e8>

I decided to check the distribution of our LOT_SIZE by making a seaborn plot.

In [24]:
sns.distplot(df["LOT_SIZE"])
/anaconda3/lib/python3.6/site-packages/matplotlib/axes/_axes.py:6499: MatplotlibDeprecationWarning: 
The 'normed' kwarg was deprecated in Matplotlib 2.1 and will be removed in 3.1. Use 'density' instead.
  alternative="'density'", removal="3.1")
Out[24]:
<matplotlib.axes._subplots.AxesSubplot at 0x1a171ae668>

The chart above shows that one of the houses is 400 million square feet, which is too large to be correct. Wikipedia indicates that the largest homes in the world are only about 200,000 square feet.

Normally, I would check in with the data owner to understand more about why this value would appear, but in this case, I decided to proceed with the analysis after removing properties with LOT_SIZE values that were obviously too large (e.g. over 200000 square feet).

In [25]:
df.loc[df.LOT_SIZE>200000]
Out[25]:
PRICE BEDS BATHS LOT_SIZE YEAR_BUILT DAYS_ON_MARKET SALE_TYPE_MLS Listing PROPERTY_TYPE_Multi-Family (5+ Unit) LOCATION_Humboldt Park LOCATION_Lincoln Park LOCATION_Logan Square LOCATION_West Town
35 1175000 11 6.0 30714156.0 1887.0 13 1 1 0 0 0 1
114 1899000 8 4.0 408375000.0 1892.0 51 1 0 0 0 1 0

If we exclude these found bad data, we can see the improvement in our distribution.

In [26]:
df = df.loc[df.LOT_SIZE<200000]
In [27]:
df.shape
Out[27]:
(123, 12)
In [28]:
sns.distplot(df.loc[df["LOT_SIZE"] < 300000, "LOT_SIZE"])
/anaconda3/lib/python3.6/site-packages/matplotlib/axes/_axes.py:6499: MatplotlibDeprecationWarning: 
The 'normed' kwarg was deprecated in Matplotlib 2.1 and will be removed in 3.1. Use 'density' instead.
  alternative="'density'", removal="3.1")
Out[28]:
<matplotlib.axes._subplots.AxesSubplot at 0x1a17855048>

The line still doesn't seem to follow the dots as closely as you'd expect, and it's probably because of that significant outlier on the right.

In [29]:
sns.pairplot(df, x_vars=['LOT_SIZE'], y_vars='PRICE', size=7, aspect=0.7, kind='reg')
Out[29]:
<seaborn.axisgrid.PairGrid at 0x1a17b5bda0>

Also, a one might expect a price decrease, on the properties, have been held on the market for a long time; we can see from our plot that this is not an attribute that affects the price much. I can think of that as an unwillingness of the property owner's to bend the price under the given amount. However, using an agent's service and getting a 5+ Unit Multi-Family properties indeed affect the price.

In [30]:
sns.pairplot(df, x_vars=['DAYS_ON_MARKET','SALE_TYPE_MLS Listing', 'PROPERTY_TYPE_Multi-Family (5+ Unit)'], y_vars='PRICE', size=7, aspect=0.7, kind='reg')
Out[30]:
<seaborn.axisgrid.PairGrid at 0x1a17dde048>

Let's analyze the effect of locations on our price.

In [31]:
df.head()
Out[31]:
PRICE BEDS BATHS LOT_SIZE YEAR_BUILT DAYS_ON_MARKET SALE_TYPE_MLS Listing PROPERTY_TYPE_Multi-Family (5+ Unit) LOCATION_Humboldt Park LOCATION_Lincoln Park LOCATION_Logan Square LOCATION_West Town
0 1699000 37 24.0 2613.0 1889.0 13 1 1 0 0 0 1
1 1495000 27 21.0 3049.0 1888.0 10 1 1 0 1 0 0
2 449900 8 4.0 3000.0 1883.0 6 1 0 0 0 0 1
3 418900 7 2.0 3484.0 1905.0 35 1 0 0 0 0 1
4 549000 8 3.0 2613.0 1905.0 26 1 0 0 0 0 1
In [32]:
df[['LOCATION_Humboldt Park', 'LOCATION_Lincoln Park',
       'LOCATION_Logan Square', 'LOCATION_West Town']].idxmax(axis=1).head()
Out[32]:
0       LOCATION_West Town
1    LOCATION_Lincoln Park
2       LOCATION_West Town
3       LOCATION_West Town
4       LOCATION_West Town
dtype: object

From our violin plot, we see the highest medium price falls into a Lincoln Park location range and the least expensive into a Humboldt Park.

In [33]:
sns.violinplot(y=df[['LOCATION_Humboldt Park', 'LOCATION_Lincoln Park',
       'LOCATION_Logan Square', 'LOCATION_West Town']].idxmax(axis=1), x=df['PRICE'], orient='h')
Out[33]:
<matplotlib.axes._subplots.AxesSubplot at 0x1a180c2c50>

From our distribution plot below, we notice that no one is selling cheap houses and a few houses that are over $2,000,000.

In [34]:
sns.distplot([df['PRICE']])
plt.xlabel('PRICE')
plt.title('Distribution of the PRICE')
/anaconda3/lib/python3.6/site-packages/matplotlib/axes/_axes.py:6499: MatplotlibDeprecationWarning: 
The 'normed' kwarg was deprecated in Matplotlib 2.1 and will be removed in 3.1. Use 'density' instead.
  alternative="'density'", removal="3.1")
Out[34]:
Text(0.5, 1.0, 'Distribution of the PRICE')

Machine Learning (Random Forest regression)

I will use a Random Forest Regressor since the target variable is a continuous real number.

In [35]:
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import train_test_split
In [36]:
X = df.drop('PRICE',axis=1)
y = df['PRICE']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
In [37]:
print(X_train.shape)
print(y_train.shape)
(98, 11)
(98,)
In [38]:
print(X_test.shape)
print(y_test.shape)
(25, 11)
(25,)
In [39]:
model = RandomForestRegressor()
model.fit(X_train, y_train)
Out[39]:
RandomForestRegressor(bootstrap=True, criterion='mse', max_depth=None,
           max_features='auto', max_leaf_nodes=None,
           min_impurity_decrease=0.0, min_impurity_split=None,
           min_samples_leaf=1, min_samples_split=2,
           min_weight_fraction_leaf=0.0, n_estimators=10, n_jobs=1,
           oob_score=False, random_state=None, verbose=0, warm_start=False)
In [40]:
y_pred = model.predict(X_test)

From here we can build our plot and add the prediction line.

In [41]:
plt.scatter(y_pred, y_test)
plt.xlabel('Prediction')
plt.ylabel('Real value')

diagonal = np.linspace(0, np.max(y_test), 100)
plt.plot(diagonal, diagonal, '-r')
plt.show()
In [42]:
from sklearn.metrics import mean_squared_log_error, mean_absolute_error

print('MAE:\t$%.2f' % mean_absolute_error(y_test, y_pred))
print('MSLE:\t%.5f' % mean_squared_log_error(y_test, y_pred))
MAE:	$213688.20
MSLE:	0.06471

From the table below we can evaluate the importance of our features.

In [43]:
feature_evaluation = pd.DataFrame(100*model.feature_importances_,X.columns).sort_values(by=0, ascending=False)
feature_evaluation.columns = ['feature_importance']
feature_evaluation
Out[43]:
feature_importance
BATHS 42.330296
DAYS_ON_MARKET 13.807564
BEDS 13.665415
YEAR_BUILT 12.507038
LOT_SIZE 10.881776
LOCATION_Lincoln Park 5.495590
LOCATION_West Town 0.828928
LOCATION_Logan Square 0.230226
LOCATION_Humboldt Park 0.218213
PROPERTY_TYPE_Multi-Family (5+ Unit) 0.034954
SALE_TYPE_MLS Listing 0.000000

Linear Regression

Let's try a Linear Regression model and compare it with RF.

In [44]:
from sklearn.linear_model import LinearRegression

reg = LinearRegression()
reg.fit(X_train, y_train)
lin_pred = reg.predict(X_test)
In [45]:
plt.scatter(lin_pred, y_test)
plt.xlabel('Prediction')
plt.ylabel('Real value')

diagonal = np.linspace(0, np.max(y_test), 100)
plt.plot(diagonal, diagonal, '-r')
plt.show()
In [46]:
print('MAE:\t$%.2f' % mean_absolute_error(y_test, lin_pred))
print('MSLE:\t%.5f' % mean_squared_log_error(y_test, lin_pred))
MAE:	$188805.56
MSLE:	0.05741

A deviation of $188805.56,- which is mainly due to the extreme outliers.

We can evaluate the coefficients of each feature as well.

In [47]:
coefficients = pd.DataFrame(reg.coef_,X_train.columns).sort_values(by=0,ascending=False)
coefficients.columns = ['coefficients']
coefficients
Out[47]:
coefficients
PROPERTY_TYPE_Multi-Family (5+ Unit) 788064.963163
LOCATION_Lincoln Park 316839.030756
SALE_TYPE_MLS Listing 45354.795936
BATHS 44871.616201
YEAR_BUILT 4622.630683
DAYS_ON_MARKET 145.715424
LOT_SIZE -6.985470
BEDS -9278.229730
LOCATION_West Town -55198.915710
LOCATION_Logan Square -102332.034740
LOCATION_Humboldt Park -113953.284370

Conclusion

We can see some coefficient discrepancies in both algorithms. There are some clear outliers in the dataset that we are predicting very badly, so it would be a good idea to understand why we're so far off with those.

Housing prices are correlated with location. With neighborhoods like Lincoln Park, it's far more expensive on average than neighborhoods like Humboldt Park. It's also apparent that the type and age of the property affect the price as well. 5+ Unit type properties cost more expensive compared to 2-4 Unit. If the data were available, it could be illuminating to analyze the impact of features like the presence or absence of a garage or porch, the overall condition of the house, air quality and safety of the neighborhood and so on.