feat/exploratory-data-analysis (#2)
Browse files- ✨ Add the prices historical graphs (b77833c647678df675d9f6d1c71729160ca68153)
- .streamlit/config.toml +3 -0
- Dockerfile +2 -1
- requirements.txt +3 -1
- src/Home.py +21 -0
- src/app.py +0 -40
- src/pages/1_Historical_Price_-_France.py +190 -0
- src/pages/2_Historical_Price_-_Region.py +238 -0
- src/pages/3_About_Us.py +24 -0
.streamlit/config.toml
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[server]
|
| 2 |
+
|
| 3 |
+
maxMessageSize = 300
|
Dockerfile
CHANGED
|
@@ -18,10 +18,11 @@ COPY --chown=user requirements.txt ./
|
|
| 18 |
RUN pip3 install -r requirements.txt
|
| 19 |
|
| 20 |
COPY --chown=user README.md ./
|
|
|
|
| 21 |
COPY --chown=user src/ ./src/
|
| 22 |
|
| 23 |
EXPOSE 8501
|
| 24 |
|
| 25 |
HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
|
| 26 |
|
| 27 |
-
ENTRYPOINT ["streamlit", "run", "src/
|
|
|
|
| 18 |
RUN pip3 install -r requirements.txt
|
| 19 |
|
| 20 |
COPY --chown=user README.md ./
|
| 21 |
+
COPY --chown=user .streamlit/ ./.streamlit/
|
| 22 |
COPY --chown=user src/ ./src/
|
| 23 |
|
| 24 |
EXPOSE 8501
|
| 25 |
|
| 26 |
HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
|
| 27 |
|
| 28 |
+
ENTRYPOINT ["streamlit", "run", "src/Home.py", "--server.port=8501", "--server.address=0.0.0.0"]
|
requirements.txt
CHANGED
|
@@ -1,3 +1,5 @@
|
|
| 1 |
altair
|
| 2 |
pandas
|
| 3 |
-
streamlit
|
|
|
|
|
|
|
|
|
| 1 |
altair
|
| 2 |
pandas
|
| 3 |
+
streamlit
|
| 4 |
+
boto3
|
| 5 |
+
plotly
|
src/Home.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Home.py
|
| 2 |
+
import streamlit as st
|
| 3 |
+
|
| 4 |
+
st.set_page_config(page_title="Multi-Page App Home", page_icon="🏠", layout="centered")
|
| 5 |
+
|
| 6 |
+
# This project aims to predict real estate prices, primarily focusing on the impact of **climatic events**. Our goal is to identify **safe and profitable locations** by analyzing how various weather and climate patterns influence property values. As the project evolves, we plan to incorporate other significant events that might affect real estate prices.
|
| 7 |
+
|
| 8 |
+
st.title("Welcome to Oasis! 🏠")
|
| 9 |
+
st.write(
|
| 10 |
+
"""
|
| 11 |
+
Oasis is a project designed to predict real estate prices, focusing on the impact of climatic events. Our goal is to identify safe and profitable locations by analyzing how various weather and climate patterns influence property values.
|
| 12 |
+
|
| 13 |
+
**How this works:**
|
| 14 |
+
1. **Data Collection:** We gather data on real estate prices and climatic events.
|
| 15 |
+
2. **Data Analysis:** We analyze the data to understand how different climatic factors affect property values.
|
| 16 |
+
3. **Model Training:** We train machine learning models to predict real estate prices based on climatic conditions and climatic conditions.
|
| 17 |
+
4. **Location Assessment:** We assess locations for safety and profitability based on our predictions.
|
| 18 |
+
"""
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
st.info("👈 Select a page from the sidebar to get started!")
|
src/app.py
DELETED
|
@@ -1,40 +0,0 @@
|
|
| 1 |
-
import altair as alt
|
| 2 |
-
import numpy as np
|
| 3 |
-
import pandas as pd
|
| 4 |
-
import streamlit as st
|
| 5 |
-
|
| 6 |
-
"""
|
| 7 |
-
# Welcome to Streamlit!
|
| 8 |
-
|
| 9 |
-
Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
|
| 10 |
-
If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
|
| 11 |
-
forums](https://discuss.streamlit.io).
|
| 12 |
-
|
| 13 |
-
In the meantime, below is an example of what you can do with just a few lines of code:
|
| 14 |
-
"""
|
| 15 |
-
|
| 16 |
-
num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
|
| 17 |
-
num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
|
| 18 |
-
|
| 19 |
-
indices = np.linspace(0, 1, num_points)
|
| 20 |
-
theta = 2 * np.pi * num_turns * indices
|
| 21 |
-
radius = indices
|
| 22 |
-
|
| 23 |
-
x = radius * np.cos(theta)
|
| 24 |
-
y = radius * np.sin(theta)
|
| 25 |
-
|
| 26 |
-
df = pd.DataFrame({
|
| 27 |
-
"x": x,
|
| 28 |
-
"y": y,
|
| 29 |
-
"idx": indices,
|
| 30 |
-
"rand": np.random.randn(num_points),
|
| 31 |
-
})
|
| 32 |
-
|
| 33 |
-
st.altair_chart(alt.Chart(df, height=700, width=700)
|
| 34 |
-
.mark_point(filled=True)
|
| 35 |
-
.encode(
|
| 36 |
-
x=alt.X("x", axis=None),
|
| 37 |
-
y=alt.Y("y", axis=None),
|
| 38 |
-
color=alt.Color("idx", legend=None, scale=alt.Scale()),
|
| 39 |
-
size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
|
| 40 |
-
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/pages/1_Historical_Price_-_France.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import streamlit as st
|
| 3 |
+
import os
|
| 4 |
+
import boto3
|
| 5 |
+
import json
|
| 6 |
+
import urllib.request
|
| 7 |
+
import io
|
| 8 |
+
import plotly.colors as pcolors
|
| 9 |
+
import plotly.express as px
|
| 10 |
+
|
| 11 |
+
AWS_S3_BUCKET = os.getenv("AWS_S3_BUCKET", "oasis-prd-001")
|
| 12 |
+
AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID")
|
| 13 |
+
AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY")
|
| 14 |
+
|
| 15 |
+
st.set_page_config(page_title="Oasis", page_icon=":house:", layout="wide")
|
| 16 |
+
|
| 17 |
+
st.header("Historical Price - France")
|
| 18 |
+
st.subheader("An overview of real estate prices in France from 2015 to 2024")
|
| 19 |
+
|
| 20 |
+
st.write(
|
| 21 |
+
"This map shows the average price per square meter in French departments over the years, with a focus on climatic events."
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def load_file_s3(object_key: str) -> pd.DataFrame:
|
| 26 |
+
"""Load a file from S3 and return its contents as a pandas DataFrame."""
|
| 27 |
+
if not AWS_S3_BUCKET or not AWS_ACCESS_KEY_ID or not AWS_SECRET_ACCESS_KEY:
|
| 28 |
+
raise ValueError(
|
| 29 |
+
"AWS credentials or bucket name not set in environment variables."
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
s3_client = boto3.client(
|
| 33 |
+
"s3",
|
| 34 |
+
aws_access_key_id=AWS_ACCESS_KEY_ID,
|
| 35 |
+
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
response = s3_client.get_object(Bucket=AWS_S3_BUCKET, Key=object_key)
|
| 39 |
+
status = response.get("ResponseMetadata", {}).get("HTTPStatusCode")
|
| 40 |
+
|
| 41 |
+
if status == 200:
|
| 42 |
+
return pd.read_csv(io.StringIO(response["Body"].read().decode("utf-8")))
|
| 43 |
+
raise ValueError(f"Unsuccessful S3 get_object response. Status - {status}")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@st.cache_data
|
| 47 |
+
def load_geojson():
|
| 48 |
+
geojson_url = "https://france-geojson.gregoiredavid.fr/repo/departements.geojson"
|
| 49 |
+
with urllib.request.urlopen(geojson_url) as response:
|
| 50 |
+
departements_geojson = json.load(response)
|
| 51 |
+
return departements_geojson
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@st.cache_data
|
| 55 |
+
def load_dataset_housing_prices():
|
| 56 |
+
df = load_file_s3("processed/housing/dataset_housing_prices.csv")
|
| 57 |
+
return df
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@st.cache_data
|
| 61 |
+
def load_dataset_housing_departement_prices_full():
|
| 62 |
+
df = load_file_s3("processed/housing/dataset_housing_departement_prices_full.csv")
|
| 63 |
+
return df
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
#####################################################################
|
| 67 |
+
# Data loading
|
| 68 |
+
#####################################################################
|
| 69 |
+
|
| 70 |
+
dataset_housing_prices = load_dataset_housing_prices()
|
| 71 |
+
dataset_housing_departement_prices_full = load_dataset_housing_departement_prices_full()
|
| 72 |
+
departements_geojson = load_geojson()
|
| 73 |
+
|
| 74 |
+
#####################################################################
|
| 75 |
+
# Data processing
|
| 76 |
+
#####################################################################
|
| 77 |
+
|
| 78 |
+
MISSING_VALUE_PLACEHOLDER = -1
|
| 79 |
+
dataset_departements_housing_prices = (
|
| 80 |
+
dataset_housing_prices.groupby(["code_departement", "annee"])["prixm2moyen"]
|
| 81 |
+
.mean()
|
| 82 |
+
.reset_index()
|
| 83 |
+
)
|
| 84 |
+
min_actual_departement_prixm2moyen = dataset_departements_housing_prices[
|
| 85 |
+
"prixm2moyen"
|
| 86 |
+
].min()
|
| 87 |
+
max_actual_departement_prixm2moyen = dataset_departements_housing_prices[
|
| 88 |
+
"prixm2moyen"
|
| 89 |
+
].max()
|
| 90 |
+
|
| 91 |
+
missing_rows = dataset_housing_departement_prices_full[
|
| 92 |
+
~dataset_housing_departement_prices_full.set_index(
|
| 93 |
+
["code_departement", "annee"]
|
| 94 |
+
).index.isin(
|
| 95 |
+
dataset_departements_housing_prices.set_index(
|
| 96 |
+
["code_departement", "annee"]
|
| 97 |
+
).index
|
| 98 |
+
)
|
| 99 |
+
]
|
| 100 |
+
|
| 101 |
+
missing_rows = missing_rows[["code_departement", "annee"]]
|
| 102 |
+
missing_rows["prixm2moyen"] = (
|
| 103 |
+
MISSING_VALUE_PLACEHOLDER # Set a default value for prixm2moyen
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
dataset_departements_housing_prices = pd.concat(
|
| 107 |
+
[dataset_departements_housing_prices, missing_rows], ignore_index=True
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
#####################################################################
|
| 111 |
+
# Graphical representation of the data
|
| 112 |
+
#####################################################################
|
| 113 |
+
|
| 114 |
+
color_range_min = MISSING_VALUE_PLACEHOLDER
|
| 115 |
+
color_range_max = max_actual_departement_prixm2moyen
|
| 116 |
+
|
| 117 |
+
normalized_min_actual = (min_actual_departement_prixm2moyen - color_range_min) / (
|
| 118 |
+
color_range_max - color_range_min
|
| 119 |
+
)
|
| 120 |
+
normalized_max_actual = (max_actual_departement_prixm2moyen - color_range_min) / (
|
| 121 |
+
color_range_max - color_range_min
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
custom_colorscale = []
|
| 125 |
+
# Add the color for missing values
|
| 126 |
+
custom_colorscale.append([0.0, "lightgrey"])
|
| 127 |
+
reversed_rdylgn_colors = pcolors.diverging.RdYlGn[::-1] # <--- Correct way to reverse
|
| 128 |
+
# Add the reversed RdYlGn colors for the actual data range
|
| 129 |
+
num_steps = len(reversed_rdylgn_colors)
|
| 130 |
+
for i, color in enumerate(reversed_rdylgn_colors):
|
| 131 |
+
normalized_point = normalized_min_actual + (
|
| 132 |
+
normalized_max_actual - normalized_min_actual
|
| 133 |
+
) * (i / (num_steps - 1))
|
| 134 |
+
if normalized_point > 0.0: # Ensure we don't overwrite the grey for missing
|
| 135 |
+
custom_colorscale.append([normalized_point, color])
|
| 136 |
+
|
| 137 |
+
# Sort the custom_colorscale by the normalized value to ensure correct order
|
| 138 |
+
custom_colorscale = sorted(custom_colorscale, key=lambda x: x[0])
|
| 139 |
+
|
| 140 |
+
fig = px.choropleth_map(
|
| 141 |
+
dataset_departements_housing_prices,
|
| 142 |
+
geojson=departements_geojson,
|
| 143 |
+
locations="code_departement",
|
| 144 |
+
featureidkey="properties.code",
|
| 145 |
+
color="prixm2moyen",
|
| 146 |
+
range_color=[
|
| 147 |
+
min_actual_departement_prixm2moyen,
|
| 148 |
+
max_actual_departement_prixm2moyen,
|
| 149 |
+
],
|
| 150 |
+
color_continuous_scale=custom_colorscale,
|
| 151 |
+
center={"lat": 46.6, "lon": 2.6},
|
| 152 |
+
zoom=5,
|
| 153 |
+
opacity=0.75,
|
| 154 |
+
hover_name="code_departement",
|
| 155 |
+
hover_data={
|
| 156 |
+
"prixm2moyen": ":.0f",
|
| 157 |
+
"annee": True, # Include year in hover data
|
| 158 |
+
},
|
| 159 |
+
title="Average Price per Square Meter in French Departments (2015-2024)",
|
| 160 |
+
height=1000,
|
| 161 |
+
animation_frame="annee",
|
| 162 |
+
animation_group="code_departement",
|
| 163 |
+
)
|
| 164 |
+
fig.update_traces(marker_line_width=0)
|
| 165 |
+
if fig.layout.updatemenus:
|
| 166 |
+
try:
|
| 167 |
+
fig.layout.updatemenus[0].buttons[0].args[1]["frame"]["duration"] = (
|
| 168 |
+
1000 # milliseconds per frame
|
| 169 |
+
)
|
| 170 |
+
fig.layout.updatemenus[0].buttons[0].args[1]["transition"]["duration"] = (
|
| 171 |
+
500 # transition duration
|
| 172 |
+
)
|
| 173 |
+
except IndexError:
|
| 174 |
+
print(
|
| 175 |
+
"Could not set animation speed. updatemenus structure might be unexpected."
|
| 176 |
+
)
|
| 177 |
+
else:
|
| 178 |
+
print(
|
| 179 |
+
"No animation updatemenus found. This usually means 'animation_frame' column has too few unique values or data issues."
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 183 |
+
|
| 184 |
+
st.write("Hover over the map to see detailed information for each department and year.")
|
| 185 |
+
st.write(
|
| 186 |
+
"Missing values are represented in light grey, while actual data is shown in a gradient from red (high prices) to green (low prices)."
|
| 187 |
+
)
|
| 188 |
+
st.write(
|
| 189 |
+
"Note: The color scale is customized to highlight missing values in light grey, while the actual data is represented using a reversed RdYlGn color scale, where red indicates higher prices and green indicates lower prices."
|
| 190 |
+
)
|
src/pages/2_Historical_Price_-_Region.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import streamlit as st
|
| 3 |
+
import os
|
| 4 |
+
import boto3
|
| 5 |
+
import json
|
| 6 |
+
import urllib.request
|
| 7 |
+
import io
|
| 8 |
+
import plotly.colors as pcolors
|
| 9 |
+
import plotly.express as px
|
| 10 |
+
|
| 11 |
+
AWS_S3_BUCKET = os.getenv("AWS_S3_BUCKET", "oasis-prd-001")
|
| 12 |
+
AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID")
|
| 13 |
+
AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY")
|
| 14 |
+
|
| 15 |
+
# --- Streamlit Page Configuration ---
|
| 16 |
+
st.set_page_config(page_title="Oasis", page_icon=":house:", layout="wide")
|
| 17 |
+
|
| 18 |
+
st.header("Historical Price - Region")
|
| 19 |
+
st.subheader("An overview of real estate prices for each region from 2015 to 2024")
|
| 20 |
+
|
| 21 |
+
st.write(
|
| 22 |
+
"This map shows the average price per square meter for each city over the years, with a focus on climatic events."
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# --- Data Loading Functions ---
|
| 26 |
+
def load_file_s3(object_key: str) -> pd.DataFrame:
|
| 27 |
+
"""Load a file from S3 and return its contents as a pandas DataFrame."""
|
| 28 |
+
if not AWS_S3_BUCKET or not AWS_ACCESS_KEY_ID or not AWS_SECRET_ACCESS_KEY:
|
| 29 |
+
raise ValueError(
|
| 30 |
+
"AWS credentials or bucket name not set in environment variables."
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
s3_client = boto3.client(
|
| 34 |
+
"s3",
|
| 35 |
+
aws_access_key_id=AWS_ACCESS_KEY_ID,
|
| 36 |
+
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
response = s3_client.get_object(Bucket=AWS_S3_BUCKET, Key=object_key)
|
| 40 |
+
status = response.get("ResponseMetadata", {}).get("HTTPStatusCode")
|
| 41 |
+
|
| 42 |
+
if status == 200:
|
| 43 |
+
# Ensure proper decoding and file-like object for pandas
|
| 44 |
+
return pd.read_csv(io.StringIO(response["Body"].read().decode("utf-8")))
|
| 45 |
+
raise ValueError(f"Unsuccessful S3 get_object response. Status - {status}")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@st.cache_data
|
| 49 |
+
def load_geojson():
|
| 50 |
+
"""Loads GeoJSON data from a URL and caches it."""
|
| 51 |
+
geojson_url = "https://france-geojson.gregoiredavid.fr/repo/communes.geojson"
|
| 52 |
+
with urllib.request.urlopen(geojson_url) as response:
|
| 53 |
+
communes_geojson = json.load(response)
|
| 54 |
+
return communes_geojson
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@st.cache_data
|
| 58 |
+
def load_dataset_housing_prices():
|
| 59 |
+
"""Loads the main housing prices dataset from S3 and caches it."""
|
| 60 |
+
df = load_file_s3("processed/housing/dataset_housing_prices.csv")
|
| 61 |
+
return df
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@st.cache_data
|
| 65 |
+
def load_dataset_housing_prices_full():
|
| 66 |
+
"""Loads the full housing prices dataset from S3 and caches it."""
|
| 67 |
+
df = load_file_s3("processed/housing/dataset_housing_prices_full.csv")
|
| 68 |
+
return df
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# --- Data Preprocessing Function (NEW: Cached for efficiency) ---
|
| 72 |
+
@st.cache_data
|
| 73 |
+
def preprocess_housing_data(df_prices, df_full):
|
| 74 |
+
"""
|
| 75 |
+
Performs all necessary data preprocessing steps and caches the result.
|
| 76 |
+
This function will only re-run if df_prices or df_full change.
|
| 77 |
+
"""
|
| 78 |
+
MISSING_VALUE_PLACEHOLDER = -1
|
| 79 |
+
|
| 80 |
+
# Calculate min/max from the original (non-concatenated) dataset
|
| 81 |
+
min_actual_country_prixm2moyen = df_prices["prixm2moyen"].min()
|
| 82 |
+
max_actual_country_prixm2moyen = df_prices["prixm2moyen"].max()
|
| 83 |
+
|
| 84 |
+
# Identify missing rows from the full dataset
|
| 85 |
+
missing_rows = df_full[
|
| 86 |
+
~df_full.set_index(["code_commune_insee", "annee"]).index.isin(
|
| 87 |
+
df_prices.set_index(["code_commune_insee", "annee"]).index
|
| 88 |
+
)
|
| 89 |
+
]
|
| 90 |
+
missing_rows = missing_rows[["code_commune_insee", "annee"]]
|
| 91 |
+
missing_rows["prixm2moyen"] = MISSING_VALUE_PLACEHOLDER
|
| 92 |
+
|
| 93 |
+
# Concatenate and add department code
|
| 94 |
+
processed_df = pd.concat([df_prices, missing_rows], ignore_index=True)
|
| 95 |
+
processed_df["code_departement"] = processed_df["code_commune_insee"].str[:2]
|
| 96 |
+
|
| 97 |
+
return processed_df, min_actual_country_prixm2moyen, max_actual_country_prixm2moyen
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# --- Plotly Figure Creation Function (NEW: Cached for efficiency) ---
|
| 101 |
+
@st.cache_data
|
| 102 |
+
def create_animated_choropleth_map(
|
| 103 |
+
filtered_df,
|
| 104 |
+
communes_geojson,
|
| 105 |
+
min_actual_country_prixm2moyen,
|
| 106 |
+
max_actual_country_prixm2moyen,
|
| 107 |
+
):
|
| 108 |
+
"""
|
| 109 |
+
Creates and caches the Plotly choropleth map figure.
|
| 110 |
+
This function will only re-run if filtered_df, communes_geojson,
|
| 111 |
+
or the min/max price values change.
|
| 112 |
+
"""
|
| 113 |
+
MISSING_VALUE_PLACEHOLDER = -1 # Needs to be consistent with preprocessing
|
| 114 |
+
|
| 115 |
+
color_range_min = MISSING_VALUE_PLACEHOLDER
|
| 116 |
+
color_range_max = max_actual_country_prixm2moyen
|
| 117 |
+
|
| 118 |
+
# Normalize the actual min/max to a 0-1 scale for defining the custom colorscale points
|
| 119 |
+
normalized_min_actual = (min_actual_country_prixm2moyen - color_range_min) / (
|
| 120 |
+
color_range_max - color_range_min
|
| 121 |
+
)
|
| 122 |
+
normalized_max_actual = (max_actual_country_prixm2moyen - color_range_min) / (
|
| 123 |
+
color_range_max - color_range_min
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
custom_colorscale = []
|
| 127 |
+
# Add the color for missing values
|
| 128 |
+
custom_colorscale.append([0.0, "lightgrey"])
|
| 129 |
+
reversed_rdylgn_colors = pcolors.diverging.RdYlGn[::-1]
|
| 130 |
+
# Add the reversed RdYlGn colors for the actual data range
|
| 131 |
+
num_steps = len(reversed_rdylgn_colors)
|
| 132 |
+
for i, color in enumerate(reversed_rdylgn_colors):
|
| 133 |
+
normalized_point = normalized_min_actual + (
|
| 134 |
+
normalized_max_actual - normalized_min_actual
|
| 135 |
+
) * (i / (num_steps - 1))
|
| 136 |
+
if normalized_point > 0.0: # Ensure we don't overwrite the grey for missing
|
| 137 |
+
custom_colorscale.append([normalized_point, color])
|
| 138 |
+
|
| 139 |
+
# Sort the custom_colorscale by the normalized value to ensure correct order
|
| 140 |
+
custom_colorscale = sorted(custom_colorscale, key=lambda x: x[0])
|
| 141 |
+
|
| 142 |
+
fig = px.choropleth_map(
|
| 143 |
+
filtered_df,
|
| 144 |
+
geojson=communes_geojson,
|
| 145 |
+
locations="code_commune_insee",
|
| 146 |
+
featureidkey="properties.code",
|
| 147 |
+
color="prixm2moyen",
|
| 148 |
+
range_color=[min_actual_country_prixm2moyen, max_actual_country_prixm2moyen],
|
| 149 |
+
color_continuous_scale=custom_colorscale,
|
| 150 |
+
center={"lat": 46.6, "lon": 2.6},
|
| 151 |
+
zoom=5,
|
| 152 |
+
opacity=0.75,
|
| 153 |
+
hover_name="code_commune_insee",
|
| 154 |
+
hover_data={
|
| 155 |
+
"prixm2moyen": ":.0f",
|
| 156 |
+
"annee": True, # Include year in hover data
|
| 157 |
+
},
|
| 158 |
+
title="Average Price per Square Meter in French Communes (2015-2024)",
|
| 159 |
+
height=800,
|
| 160 |
+
animation_frame="annee",
|
| 161 |
+
animation_group="code_commune_insee",
|
| 162 |
+
)
|
| 163 |
+
fig.update_traces(marker_line_width=0)
|
| 164 |
+
|
| 165 |
+
# Set animation speed (error handling for robustness)
|
| 166 |
+
if fig.layout.updatemenus:
|
| 167 |
+
try:
|
| 168 |
+
fig.layout.updatemenus[0].buttons[0].args[1]["frame"]["duration"] = 1500
|
| 169 |
+
fig.layout.updatemenus[0].buttons[0].args[1]["transition"]["duration"] = 500
|
| 170 |
+
except IndexError:
|
| 171 |
+
st.warning(
|
| 172 |
+
"Could not set animation speed. Updatemenus structure might be unexpected."
|
| 173 |
+
)
|
| 174 |
+
else:
|
| 175 |
+
st.warning(
|
| 176 |
+
"No animation updatemenus found. This usually means 'animation_frame' column has too few unique values or data issues."
|
| 177 |
+
)
|
| 178 |
+
return fig
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
#####################################################################
|
| 182 |
+
# Main Streamlit App Logic
|
| 183 |
+
#####################################################################
|
| 184 |
+
|
| 185 |
+
# Use st.spinner for initial loading and preprocessing
|
| 186 |
+
with st.spinner("Loading and preprocessing data... This might take a moment."):
|
| 187 |
+
# Load the raw datasets (these are cached)
|
| 188 |
+
dataset_housing_prices = load_dataset_housing_prices()
|
| 189 |
+
dataset_housing_prices_full = load_dataset_housing_prices_full()
|
| 190 |
+
communes_geojson = load_geojson()
|
| 191 |
+
|
| 192 |
+
# Preprocess the data (this result is cached)
|
| 193 |
+
(
|
| 194 |
+
processed_housing_data,
|
| 195 |
+
min_actual_country_prixm2moyen,
|
| 196 |
+
max_actual_country_prixm2moyen,
|
| 197 |
+
) = preprocess_housing_data(dataset_housing_prices, dataset_housing_prices_full)
|
| 198 |
+
|
| 199 |
+
# Dropdown for department selection (this interaction triggers a rerun)
|
| 200 |
+
st.subheader("Select a Department to View Commune Prices")
|
| 201 |
+
selected_departement = st.selectbox(
|
| 202 |
+
"Select a Department",
|
| 203 |
+
options=processed_housing_data["code_departement"].unique(),
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
# Filter data based on selected department (this happens on every rerun after selection)
|
| 207 |
+
# This filtering is fast on the already preprocessed data.
|
| 208 |
+
filtered_data_for_map = processed_housing_data[
|
| 209 |
+
processed_housing_data["code_departement"] == selected_departement
|
| 210 |
+
].copy() # Use .copy() to avoid SettingWithCopyWarning
|
| 211 |
+
filtered_data_for_map = filtered_data_for_map[
|
| 212 |
+
["code_commune_insee", "annee", "prixm2moyen"]
|
| 213 |
+
]
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
# Create and display the choropleth map (this result is cached based on filtered_data_for_map)
|
| 217 |
+
fig = create_animated_choropleth_map(
|
| 218 |
+
filtered_data_for_map,
|
| 219 |
+
communes_geojson,
|
| 220 |
+
min_actual_country_prixm2moyen,
|
| 221 |
+
max_actual_country_prixm2moyen,
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
st.subheader("Average Price per Square Meter in French Communes (2015-2024)")
|
| 225 |
+
st.write(
|
| 226 |
+
"This map shows the average price per square meter in French communes over the years, with a focus on climatic events."
|
| 227 |
+
)
|
| 228 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 229 |
+
st.write("Hover over the map to see detailed information for each commune and year.")
|
| 230 |
+
st.write(
|
| 231 |
+
"Missing values are represented in light grey, while actual data is shown in a gradient from red (high prices) to green (low prices)."
|
| 232 |
+
)
|
| 233 |
+
st.write(
|
| 234 |
+
"Note: The color scale is customized to highlight missing values in light grey, while the actual data is represented using a reversed RdYlGn color scale, where red indicates higher prices and green indicates lower prices."
|
| 235 |
+
)
|
| 236 |
+
st.write(
|
| 237 |
+
"The map is animated by year, allowing you to see how the average price per square meter changes over time."
|
| 238 |
+
)
|
src/pages/3_About_Us.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Create an about page to present the team and the project in more detail
|
| 2 |
+
# The team includes:
|
| 3 |
+
# - Frederic, the project manager
|
| 4 |
+
# - Olivior, the data scientist
|
| 5 |
+
# - Nick, the developer
|
| 6 |
+
# - Faycel, the data engineer
|
| 7 |
+
# - Francis, the data analyst
|
| 8 |
+
|
| 9 |
+
import streamlit as st
|
| 10 |
+
|
| 11 |
+
st.set_page_config(page_title="About Us", page_icon="ℹ️", layout="centered")
|
| 12 |
+
st.title("ℹ️ About Us")
|
| 13 |
+
st.write(
|
| 14 |
+
"""
|
| 15 |
+
This ambitious project, Oasis, aims to predict real estate prices with a primary focus on the impact of climatic events. Our goal is to identify safe and profitable locations by analyzing how various weather and climate patterns influence property values.
|
| 16 |
+
|
| 17 |
+
## The Team
|
| 18 |
+
- Frederic, the project manager
|
| 19 |
+
- Olivior, the data scientist
|
| 20 |
+
- Nick, the developer
|
| 21 |
+
- Faycel, the data engineer
|
| 22 |
+
- Francis, the data analyst
|
| 23 |
+
"""
|
| 24 |
+
)
|