🏡 AI-Driven Agents for Fair and Efficient Property Valuation¶

  • Advisor: Dr. Ashraf Elsayed
  • Student Name: Fahad Abubaker Bahashwan
  • Institution: MidOcean University
  • Date: 30, July 2025

What we'll do:

  1. Load and explore data
  2. Create geographic features
  3. Train different ML models
  4. Compare which model works best

📚 Load Required Libraries¶

Import all the essential Python libraries needed for data processing, analysis, visualization, and modeling.

In [136]:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from geopy.distance import geodesic
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.compose import ColumnTransformer
from sklearn.linear_model import Ridge, Lasso, LinearRegression
from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor
from sklearn.tree import DecisionTreeRegressor
from sklearn.neighbors import KNeighborsRegressor
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score 
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
import xgboost as xgb

⚙️ Initialize Default Values¶

Define default configuration settings, constants, or initial parameters to be used throughout the notebook.

In [137]:
city_center_coords = {
    'Riyadh': (24.702157462388044, 46.74276616894144),
    'Jeddah': (21.55406527981118, 39.18189665824677),
    'Mecca': (21.42330879427541, 39.82553438746125),
    'Medina': (24.45808931439141, 39.62228459678812),
    'Dammam': (26.40491223507235, 50.082472821265576),
    'Khobar': (26.271817530349022, 50.204861924141625),
    'Qatif': (26.5656, 50.0089),
    'Al-Ahsa': (26.567036126182284, 50.005786609509734),
    'Jubail': (27.003708651215092, 49.629486676571645),
    'Khamis Mushait': (18.30250384919938, 42.74398498622415),
    'Buraidah': (26.356200460399695, 43.968039364588286),
    'Hafar Al-Batin': (28.41245379479697, 45.96700913116033),
    'Diriyah': (24.75019708336476, 46.53413641074703),
    'Huraymila': (25.112716354278646, 46.09974748404961),
    'Nairyah': (27.473500934006946, 48.48032843080484),
    'Jumum': (21.622696847405454, 39.69755012299724)
}

# Mapping Arabic province names to English
province_map = {
    'الرياض': 'Riyadh',
    'جدة': 'Jeddah',
    'مكة المكرمة': 'Mecca',
    'المدينة المنورة': 'Medina',
    'الدمام': 'Dammam',
    'الخبر': 'Khobar',
    'القطيف': 'Qatif',
    'الاحساء': 'Al-Ahsa',
    'الجبيل': 'Jubail',
    'خميس مشيط': 'Khamis Mushait',
    'بريدة': 'Buraidah',
    'حفر الباطن': 'Hafar Al-Batin',
    'الدرعية': 'Diriyah',
    'حـريملاء': 'Huraymila',
    'النعيرية': 'Nairyah',
    'الجموم': 'Jumum'
}




property_type = {
    'أرض سكني': 'Residential Land',
    'فلل': 'Villas',
    'شقق': 'Apartments',
    'مرافق': 'Facilities',
    'زراعي': 'Agricultural',
    'أرض تجاري': 'Commercial Land',
    'مصانع ومستودعات': 'Factories & Warehouses',
    'أرض تجاري سكني': 'Mixed-use Land',
    'اخرى': 'Other',
    'مبنى تجاري سكني': 'Mixed-use Building',
    'مبنى سكني/تجاري': 'Mixed-use Building',
    'استراحات': 'Rest Houses',
    'أرض فضاء': 'Vacant Land',
    'مبنى تجاري': 'Commercial Building',
    'شقة': 'Apartments',
    'مرفق صناعي': 'Industrial Facility',
    'استراحة': 'Rest House',
    'مبنى سكني': 'Residential Building',
    'شاليه': 'Chalet'
}

pd.set_option('display.max_columns', None)

📂 Load Original Dataset and Create a Working Copy¶

Import the original dataset and create a duplicate to preserve the raw data for reference and ensure safe preprocessing.
The dataset has been successfully loaded and contains 66,005 records

In [138]:
orginal_df = pd.read_excel("AllTransa_66006.xlsx")
In [139]:
df = orginal_df.copy()
In [140]:
df.shape
Out[140]:
(66005, 41)

🛠️ Clean Column Names and Standardize Formats¶

Refactor column names for consistency, convert Arabic labels to English, and ensure proper data formatting for analysis.

In [141]:
# Convert to datetime
df['transactionDate'] = pd.to_datetime(df['transactionDate'], errors='coerce')
df['transactionDate'] = df['transactionDate'].dt.strftime('%Y-%m-%d')
# convert from Arabic to English names
df['metricsType'] = df['metricsType'].map(property_type)
df['provinceName'] = df['provinceName'].map(province_map)
In [142]:
df['provinceName'].value_counts()
Out[142]:
provinceName
Riyadh            40332
Jeddah             6442
Mecca              4151
Dammam             3980
Buraidah           2862
Khobar             2195
Al-Ahsa            1615
Medina             1166
Huraymila          1043
Qatif               756
Jubail              636
Khamis Mushait      319
Hafar Al-Batin      239
Jumum               119
Diriyah              81
Nairyah              69
Name: count, dtype: int64

🧹 Remove Unnecessary Columns and Reorder Features¶

Drop irrelevant or redundant columns and rearrange the remaining features into a logical, consistent order.

In [143]:
# Define the columns to drop as a variable
columns_to_drop = [
    'main_key', '_priceOfMeter', 'transactionPrice', 'transactionNumber', 'type', 'subdivisionId', 'polygonData', 'noOfProperties', 'zoningId',
    'parcelId', 'parcelObjectId',  'blockNo', 'area', 'geometry','totalArea',
    'parcelImageURL', 'projectName', 'landUsageGroup', 'sellingType', 'landUseaDetailed',
    'centroid', 'propertyType', 'totalArea', 'details', 'landUseGroup',
    'orignalTransactionNum', 'propertyNumber'
]
#'isLowValueTransaction','transactionSource',
#,neighborhood', 'neighborhoodId', 'region', 'regionId', 'provinceId'
# ,'subdivisionNo' , 'parcelNo',
# Drop the columns using the variable
df = df.drop(columns=columns_to_drop)
#order columns
desired_order = [
    'transactionDate', 'centroidX', 'centroidY', 'metricsType', 'provinceName',
    'isLowValueTransaction', 'transactionSource', 'neighborhood', 'neighborhoodId',
    'region', 'regionId', 'provinceId', 'subdivisionNo', 'priceOfMeter'
]

# Filter only the columns that exist in df
existing_cols = [col for col in desired_order if col in df.columns]

# Reorder
df = df[existing_cols]
In [144]:
df['metricsType'].value_counts()
Out[144]:
metricsType
Residential Land          27737
Apartments                16854
Mixed-use Land             6880
Villas                     4701
Facilities                 4565
Agricultural               1682
Commercial Land            1430
Factories & Warehouses     1249
Mixed-use Building          602
Other                        91
Vacant Land                  81
Commercial Building          68
Rest Houses                  51
Residential Building         10
Rest House                    2
Industrial Facility           1
Chalet                        1
Name: count, dtype: int64
In [145]:
df.shape
Out[145]:
(66005, 14)
In [146]:
values_to_remove = ['Facilities','Agricultural','Factories & Warehouses','Other','Vacant Land','Commercial Building',
                   'Rest Houses','Residential Building','Rest House','Industrial Facility','Chalet','Mixed-use Building']

df = df[~df['metricsType'].isin(values_to_remove)]
In [147]:
df.shape
Out[147]:
(57602, 14)
In [148]:
df = df[df['isLowValueTransaction'] != True]   ## Remove unreal values
df.shape
Out[148]:
(56955, 14)
In [149]:
df['metricsType'].value_counts()
Out[149]:
metricsType
Residential Land    27232
Apartments          16807
Mixed-use Land       6854
Villas               4637
Commercial Land      1425
Name: count, dtype: int64
In [150]:
df.describe()
Out[150]:
centroidX centroidY neighborhoodId regionId provinceId priceOfMeter
count 56955.000000 56955.000000 5.695500e+04 56955.000000 56955.000000 56955.000000
mean 45.686881 24.402443 1.479388e+08 7.671074 77711.200176 3671.090370
std 3.389673 1.574042 2.394722e+08 3.338664 33386.180241 4496.727586
min 39.039992 18.219735 2.100030e+05 2.000000 21000.000000 11.000000
25% 46.461224 24.509398 1.010007e+07 5.000000 51000.000000 1345.000000
50% 46.725390 24.774094 1.010001e+08 10.000000 101000.000000 2600.000000
75% 46.923978 25.000986 1.010002e+08 10.000000 101000.000000 4444.000000
max 50.236258 28.460183 1.310005e+09 13.000000 131000.000000 207012.000000

🧼 Clean Price Data: Remove Zero Values and Outliers¶

  1. Remove zero values from the priceOfMeter column to eliminate invalid entries.
  2. Apply IQR filtering (Interquartile Range) to remove extreme outliers and retain data within a statistically reasonable range.
    • Outliers are defined as values outside Q1 - 1.5 * IQR and Q3 + 1.5 * IQR.
In [151]:
# 1. Remove zero prices
df2 = df[df['priceOfMeter'] > 0]

# 2. Remove outliers using IQR (Interquartile Range)
Q1 = df2['priceOfMeter'].quantile(0.25)
Q3 = df2['priceOfMeter'].quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR

# Filter rows within bounds
df2 = df2[(df2['priceOfMeter'] >= lower_bound) & (df2['priceOfMeter'] <= upper_bound)]

df2.describe()
df2.shape
Out[151]:
(52953, 14)
In [152]:
# Plot the boxplot for the column 'priceofmeter'
plt.figure(figsize=(8, 5))
sns.boxplot(data=df2, x='priceOfMeter')

plt.title('Boxplot of Price per Meter')
plt.xlabel('Price per Meter')
plt.grid(True)
plt.show()
No description has been provided for this image

➕ Feature Engineering: Add New Features¶

Create additional meaningful features to enhance model performance and capture deeper insights from the data.

In [153]:
# Calculate distance to city center using haversine Formula - https://en.wikipedia.org/wiki/Haversine_formula
def calculate_distance(row):
    lat, lon = row['centroidY'], row['centroidX']
    city = row['provinceName']
    if city in city_center_coords:
        center_lat, center_lon = city_center_coords[city]
        return geodesic((lat, lon), (center_lat, center_lon)).km
    return None  # fallback for unknown city

# Apply the function to each row to create the new feature
df2['dist_to_city_center_km'] = df2.apply(calculate_distance, axis=1)
In [154]:
# Calculate Nearby Schools Count ( 1KM / 3KM )
df_schools = pd.read_excel("schools.xlsx")
# Define haversine (vectorized)  - https://en.wikipedia.org/wiki/Haversine_formula
def haversine_np(lat1, lon1, lat2, lon2):
    R = 6371  # Earth radius in KM
    lat1, lon1, lat2, lon2 = map(np.radians, [lat1, lon1, lat2, lon2])
    dlat = lat2 - lat1
    dlon = lon2 - lon1
    a = np.sin(dlat/2.0)**2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon/2.0)**2
    c = 2 * np.arcsin(np.sqrt(a))
    return R * c

# Function to count schools within 3km for a target point
def count_schools_near_target(row, schools_df, radius_km=1):
    distances = haversine_np(row['centroidY'], row['centroidX'],
                             schools_df['latitude'].values, schools_df['longitude'].values)
    return int(np.sum(distances <= radius_km))

# Apply to each row in 
df2['schools_within_1km'] = df2.apply(
    lambda row: count_schools_near_target(row, df_schools), axis=1
)
In [155]:
# Convert `metricsType` into one-hot encoded columns and standardize column names for consistency.

df2 = pd.get_dummies(df2, columns=['metricsType'], prefix='', prefix_sep='')

# Clean column names
df2.columns = (
    df2.columns
    .str.strip()                       # Remove leading/trailing whitespace
    .str.replace(' ', '_', regex=False)  # Replace spaces with _
    .str.replace('-', '_', regex=False)  # Replace dashes with _
    .str.lower()                        # Convert to lowercase
)
CHECK Check¶
In [156]:
df2.shape
Out[156]:
(52953, 20)
In [157]:
df2.dropna(inplace=True)  # if exist
In [158]:
df2.shape
Out[158]:
(52086, 20)
In [159]:
df2.duplicated().sum()
Out[159]:
np.int64(698)
In [160]:
df2.drop_duplicates(inplace=True)
In [161]:
df2.shape
Out[161]:
(51388, 20)
In [162]:
# Clean `priceofmeter` values for each property type using IQR filtering to remove outliers. 

# ✅ Define one-hot encoded type columns
type_cols = ['apartments', 'commercial_land', 'residential_land',  'mixed_use_land', 'villas'] #'mixed_use_building',

# ✅ Label the original data for plotting
df2_long = []
for col in type_cols:
    temp = df2[df2[col] == 1].copy()
    temp['type'] = col
    temp['source'] = 'Before'
    df2_long.append(temp)
df2_before = pd.concat(df2_long)

# ✅ Clean the data using IQR per type
cleaned_dfs = []
for col in type_cols:
    type_df = df2[df2[col] == 1]
    Q1 = type_df['priceofmeter'].quantile(0.25)
    Q3 = type_df['priceofmeter'].quantile(0.75)
    IQR = Q3 - Q1
    lower = Q1 - 1.5 * IQR
    upper = Q3 + 1.5 * IQR
    filtered = type_df[(type_df['priceofmeter'] >= lower) & (type_df['priceofmeter'] <= upper)].copy()
    filtered['type'] = col
    filtered['source'] = 'After'
    cleaned_dfs.append(filtered)
df2_after = pd.concat(cleaned_dfs)

# ✅ Combine before and after for plotting
df2_plot = pd.concat([df2_before, df2_after])

# ✅ Create the boxplot
plt.figure(figsize=(14, 6))
sns.boxplot(data=df2_plot, x='type', y='priceofmeter', hue='source')
plt.title('Price of Meter: Before vs After Outlier Removal (per Property Type)')
plt.xlabel('Property Type')
plt.ylabel('Price per Meter')
plt.xticks(rotation=15)
plt.grid(True)
plt.tight_layout()
plt.show()

#df2_before.shape
df2_after.shape
No description has been provided for this image
Out[162]:
(49308, 22)

🔥 Correlation Heatmap of Numerical Features¶

Visualize the pairwise correlations between all numerical features to identify strong linear relationships and potential multicollinearity.

In [163]:
 
selected_cols = ['priceofmeter', 'centroidx', 'centroidy', 'dist_to_city_center_km','schools_within_1km']  # Replace with your desired columns
numeric_df = df2_after[selected_cols]

plt.figure(figsize=(10, 8))
sns.heatmap(numeric_df.corr(), annot=True, cmap='coolwarm')
plt.title("Correlation Heatmap (Selected Columns)")
plt.show()
No description has been provided for this image

🏗️ Feature Selection and Model Training Setup¶

Select key features to train the model and define the target variable priceofmeter.

In [164]:
features = ['centroidy', 'centroidx','schools_within_1km', 'dist_to_city_center_km', 'apartments',
            'commercial_land', 'residential_land', 'mixed_use_land','villas'] 
target = 'priceofmeter'
X = df2_after[features]
y = df2_after[target]

🤖 Train Multiple Models and Evaluate Performance¶

Split the data into training and testing sets, then apply preprocessing using pipelines:

  • Numerical features: Imputation and standard scaling
  • Categorical features: Passed through as-is

Train and evaluate multiple regression models, including:
LinearRegression, Ridge, Lasso, RandomForest, GradientBoosting, KNN, DecisionTree, and XGBoost.
Performance is assessed using R² and RMSE on both training and test sets.

In [165]:
 
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) #42

 
numeric_features = ['centroidy', 'centroidx','schools_within_1km', 'dist_to_city_center_km'] 
categorical_features = ['apartments', 'commercial_land', 'residential_land',
                        'mixed_use_land', 'villas']

 
numeric_transformer = Pipeline([
    ('imputer', SimpleImputer(strategy="mean")),
    ('scaler', StandardScaler())
])

 
preprocessor = ColumnTransformer([
    ('num', numeric_transformer, numeric_features),
    ('cat', 'passthrough', categorical_features)
])

 
models = {
    'LinearRegression': LinearRegression(),
    'Ridge': Ridge(),
    'Lasso': Lasso(),
    'RandomForest': RandomForestRegressor(n_estimators=50, random_state=42),
    'GradientBoosting': GradientBoostingRegressor(n_estimators=50, random_state=42),
    'KNN': KNeighborsRegressor(n_neighbors=5),
    'DecisionTree': DecisionTreeRegressor(random_state=42),
    'XGBoost': xgb.XGBRegressor(n_estimators=50, random_state=42)
} 

📊 Model Performance Summary¶

Display a comparison of all trained regression models based on key evaluation metrics:

  • R² Score (Train/Test)
  • RMSE (Train/Test)
In [166]:
results = []

for name, model in models.items():
    pipe = Pipeline([
        ('preprocessor', preprocessor),
        ('regressor', model)
    ])
    pipe.fit(X_train, y_train)
    
    train_pred = pipe.predict(X_train)
    test_pred = pipe.predict(X_test)
    
    train_r2 = r2_score(y_train, train_pred)
    test_r2 = r2_score(y_test, test_pred)
    train_rmse = np.sqrt(mean_squared_error(y_train, train_pred))
    test_rmse = np.sqrt(mean_squared_error(y_test, test_pred))
    train_mae = mean_absolute_error(y_train, train_pred)
    test_mae = mean_absolute_error(y_test, test_pred)

    r2_gap = train_r2 - test_r2
    rmse_gap = test_rmse - train_rmse

    # Determine Fit Status
    if test_r2 < 0.5:
        Fit_Status = '🔴 Weak / Underfitting'
    elif r2_gap > 0.20 or rmse_gap > 250:
        Fit_Status = '❌ Overfitting'
    elif r2_gap > 0.10 or rmse_gap > 150:
        Fit_Status = '⚠️ Mild Overfitting'
    elif test_r2 >= 0.75 and r2_gap < 0.05:
        Fit_Status = '✅ Excellent Fit'
    elif test_r2 >= 0.65:
        Fit_Status = '🟢 Good Fit'
    else:
        Fit_Status = '🟡 Moderate Fit'


    results.append({
        'Model': name,
        'Train_R2': train_r2,
        'Test_R2': test_r2,
        'R2_Gap': r2_gap,
        'Train_RMSE': train_rmse,
        'Test_RMSE': test_rmse,
        'RMSE_Gap': rmse_gap,  
       
        'Fit Status': Fit_Status
    })

# Convert to DataFrame
result_df = pd.DataFrame(results)

# Sort by Test_R2 descending
result_df = result_df.sort_values(by='Test_R2', ascending=False).reset_index(drop=True)

# Show result
result_df.head(100)
Out[166]:
Model Train_R2 Test_R2 R2_Gap Train_RMSE Test_RMSE RMSE_Gap Fit Status
0 RandomForest 0.948291 0.851186 0.097106 434.145018 740.577084 306.432066 ❌ Overfitting
1 XGBoost 0.862631 0.836539 0.026092 707.616355 776.167830 68.551475 ✅ Excellent Fit
2 KNN 0.877867 0.817072 0.060795 667.220181 821.084897 153.864716 ⚠️ Mild Overfitting
3 DecisionTree 0.961352 0.799362 0.161990 375.332776 859.912353 484.579577 ❌ Overfitting
4 GradientBoosting 0.768029 0.752637 0.015392 919.539787 954.807021 35.267234 ✅ Excellent Fit
5 LinearRegression 0.452374 0.449537 0.002837 1412.847848 1424.334747 11.486899 🔴 Weak / Underfitting
6 Ridge 0.452374 0.449535 0.002839 1412.847860 1424.336800 11.488940 🔴 Weak / Underfitting
7 Lasso 0.452362 0.449356 0.003006 1412.863857 1424.568885 11.705029 🔴 Weak / Underfitting

🏆 Identify the Best Performing Model¶

Select the best model based on:

  • Highest R² on Test Set → Indicates best explanatory power
  • Lowest RMSE on Test Set → Indicates best prediction accuracy

This helps determine the most reliable model for real-world application.

In [167]:
# Filter out models flagged as overfitting
filtered_df = result_df[~result_df['Fit Status'].str.contains('Overfitting')]

if not filtered_df.empty:
    best_model_by_r2 = filtered_df.loc[filtered_df['Test_R2'].idxmax(), 'Model']
    best_model_by_rmse = filtered_df.loc[filtered_df['Test_RMSE'].idxmin(), 'Model']
else:
    # If all overfit, fallback to best Test_R2 ignoring Fit_Status
    best_model_by_r2 = result_df.loc[result_df['Test_R2'].idxmax(), 'Model']
    best_model_by_rmse = result_df.loc[result_df['Test_RMSE'].idxmin(), 'Model']

print("🏆 Best Model by R2 (filtered):", best_model_by_r2)
print("🏆 Best Model by RMSE (filtered):", best_model_by_rmse)
🏆 Best Model by R2 (filtered): XGBoost
🏆 Best Model by RMSE (filtered): XGBoost
In [168]:
filtered_df.head(10)
Out[168]:
Model Train_R2 Test_R2 R2_Gap Train_RMSE Test_RMSE RMSE_Gap Fit Status
1 XGBoost 0.862631 0.836539 0.026092 707.616355 776.167830 68.551475 ✅ Excellent Fit
4 GradientBoosting 0.768029 0.752637 0.015392 919.539787 954.807021 35.267234 ✅ Excellent Fit
5 LinearRegression 0.452374 0.449537 0.002837 1412.847848 1424.334747 11.486899 🔴 Weak / Underfitting
6 Ridge 0.452374 0.449535 0.002839 1412.847860 1424.336800 11.488940 🔴 Weak / Underfitting
7 Lasso 0.452362 0.449356 0.003006 1412.863857 1424.568885 11.705029 🔴 Weak / Underfitting
In [169]:
best_model_name = 'XGBoost'
In [170]:
# Get the best model from the dictionary (previously selected based on evaluation metrics)
best_model = models[best_model_name]

# Build a pipeline that includes preprocessing and the chosen regression model
pipe = Pipeline([
    ('preprocessor', preprocessor),  # Apply preprocessing steps (e.g., scaling, imputing, encoding)
    ('regressor', best_model)        # Use the best regression model
])

# Train (fit) the pipeline on the training data
pipe.fit(X_train, y_train)

# Make predictions on the test set
y_pred = pipe.predict(X_test)

# Calculate the residuals (errors)
residuals = y_pred - y_test
residuals_pct = (residuals / y_test) * 100  # Percentage error

# Create a DataFrame to compare actual vs predicted values
results_df = pd.DataFrame({
    'Actual': y_test.values,
    'Predicted': y_pred,
    'Difference ': residuals,
    'Error%': residuals_pct
})

# Show the first 5 rows of results
results_df.head()
Out[170]:
Actual Predicted Difference Error%
55522 2138 2019.766602 -118.233398 -5.530093
26792 8019 7544.817383 -474.182617 -5.913239
35045 2333 3591.302490 1258.302490 53.934955
51137 3928 3293.339111 -634.660889 -16.157355
2857 3263 5231.555664 1968.555664 60.329625
In [171]:
#results_df.to_excel("results.xlsx", index=False)
In [172]:
results_df.describe()
Out[172]:
Actual Predicted Difference Error%
count 9862.000000 9862.000000 9862.000000 9862.000000
mean 2719.976678 2726.398438 6.421726 22.886855
std 1919.861830 1751.865601 776.180642 223.335764
min 17.000000 -83.895554 -5426.311768 -379.651845
25% 1227.000000 1384.957092 -262.784485 -10.377111
50% 2261.000000 2433.297607 7.467590 0.407463
75% 3700.000000 3632.885986 282.035736 14.356199
max 9083.000000 8579.887695 6912.509766 8946.600932

🎯 Actual vs Predicted Plot¶

Visualize how closely the model's predictions align with the actual values.

In [173]:
plt.figure(figsize=(15, 7))
plt.scatter(results_df['Actual'], results_df['Predicted'], alpha=0.5, color='blue', label='Predictions')

# Ideal line
plt.plot(results_df['Actual'], results_df['Actual'], 'r--', label='Perfect Prediction')
 
plt.xlabel("Actual Value")
plt.ylabel("Predicted Value")
plt.title("Actual vs Predicted")
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()
No description has been provided for this image

📊 Filtered Percentage Error Distribution (±100%)¶

Display a histogram of prediction errors limited to the ±100% range.
This focuses the analysis on typical prediction behavior by excluding extreme outliers.
The red dashed line at 0% represents perfect prediction.

In [174]:
filtered = results_df[(results_df['Error%'] >= -100) & (results_df['Error%'] <= 100)]

plt.figure(figsize=(10, 6))
plt.hist(filtered['Error%'], bins=100, color='green', edgecolor='black', alpha=0.7)
plt.axvline(0, color='red', linestyle='--', linewidth=2)
plt.xlabel("Percentage Error (%)", fontsize=12)
plt.ylabel("Count", fontsize=12)
plt.title("Distribution of % Errors (Filtered ±100%)", fontsize=14)
plt.grid(True)
plt.tight_layout()
plt.show()
No description has been provided for this image

📊 Distribution of Percentage Errors (Filtered ±20%)¶

Visualize prediction errors within the ±20% range to exclude extreme outliers.
This highlights the model’s typical performance with a clearer and more focused error distribution.

In [175]:
filtered = results_df[(results_df['Error%'] >= -20) & (results_df['Error%'] <= 20)]

plt.figure(figsize=(10, 6))
plt.hist(filtered['Error%'], bins=100, color='green', edgecolor='black', alpha=0.7)
plt.axvline(0, color='red', linestyle='--', linewidth=2)
plt.xlabel("Percentage Error (%)", fontsize=12)
plt.ylabel("Count", fontsize=12)
plt.title("Distribution of % Errors (Filtered ±20%)", fontsize=14)
plt.grid(True)
plt.tight_layout()
plt.show()
No description has been provided for this image

📈 Evaluate Model Accuracy: MAPE & MAE¶

  • MAPE (Mean Absolute Percentage Error): Measures average percentage difference between actual and predicted values.
  • MAE (Mean Absolute Error): Measures average absolute difference in the same units as the target variable.
In [176]:
mape = np.mean(np.abs((y_test - y_pred) / y_test)) * 100
print(f"📉 MAPE (متوسط نسبة الخطأ): {mape:.2f}%")

mae = np.mean(np.abs(y_test - y_pred))
print(f"📏 MAE (متوسط الخطأ المطلق): {mae:.2f}")
📉 MAPE (متوسط نسبة الخطأ): 36.54%
📏 MAE (متوسط الخطأ المطلق): 487.40
In [177]:
results_df.shape
Out[177]:
(9862, 4)

📉 Line Plot: Actual vs Predicted Values (Sorted)¶

Display actual and predicted values on a sorted line plot to assess overall prediction trends.
This helps visually compare how closely the predictions follow the true values across samples.

In [178]:
# Sort by actual or index for a clean line (optional)
results_sorted = results_df.sort_values(by='Actual').reset_index(drop=True)   # row index order
 
plt.figure(figsize=(12, 6))

# Plot actual values
plt.plot(results_sorted['Actual'], label='Actual', color='black', linewidth=2)

# Plot predicted values
plt.plot(results_sorted['Predicted'], label='Predicted', color='green', alpha=0.7)

# Labels and styling
plt.title('Actual vs Predicted Values (Line Plot)', fontsize=14)
plt.xlabel('Sample Index (sorted)', fontsize=12)
plt.ylabel('Value', fontsize=12)
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()
No description has been provided for this image

📊 R² Score Comparison: Train vs Test¶

Compare the R² scores of different models on both training and testing sets.

In [179]:
 
plt.figure(figsize=(14, 6))
 
bar_width = 0.35
index = np.arange(len(result_df))

# Custom colors
train_color = '#1f77b4'   # Blue
test_color = '#aec7e8'    # Light blue

plt.bar(index, result_df["Train_R2"], bar_width, label='Train R²',color=train_color)
plt.bar(index + bar_width, result_df["Test_R2"], bar_width, label='Test R²',color=test_color)

plt.xlabel("Model")
plt.ylabel("R² Score")
plt.title("R² Comparison (Train vs Test)")
plt.xticks(index + bar_width / 2, result_df["Model"], rotation=45)
plt.legend()
plt.tight_layout()
plt.show()
No description has been provided for this image

📉 RMSE Comparison: Train vs Test¶

Visualize and compare the Root Mean Squared Error (RMSE) across models for both training and testing sets.
Lower RMSE values indicate better prediction accuracy.

In [180]:
 
plt.figure(figsize=(14, 6))
bar_width = 0.35
index = np.arange(len(result_df))

# Custom colors
train_color = '#1f77b4'   # Blue
test_color = '#aec7e8'    # Light blue

plt.bar(index, result_df["Train_RMSE"], bar_width, label='Train RMSE',color=train_color)
plt.bar(index + bar_width, result_df["Test_RMSE"], bar_width, label='Test RMSE',color=test_color)

plt.xlabel("Model")
plt.ylabel("RMSE")
plt.title("RMSE Comparison (Train vs Test)")
plt.xticks(index + bar_width / 2, result_df["Model"], rotation=45)
plt.legend()
plt.tight_layout()
plt.show()
No description has been provided for this image
In [ ]: