From fe951d811e1ac35001d213de1fd2c271447be0fe Mon Sep 17 00:00:00 2001
From: daniprec
Date: Thu, 20 May 2021 13:38:37 +0200
Subject: [PATCH 01/47] New function in plot: plot_power_distribution
---
nilmth/utils/plot.py | 33 ++++++++++++++++++++++++++++++++-
1 file changed, 32 insertions(+), 1 deletion(-)
diff --git a/nilmth/utils/plot.py b/nilmth/utils/plot.py
index e9e4a9f..584b0bf 100644
--- a/nilmth/utils/plot.py
+++ b/nilmth/utils/plot.py
@@ -1,5 +1,5 @@
-import matplotlib.pyplot as plt
import numpy as np
+from matplotlib import pyplot as plt
def plot_real_data(
@@ -66,3 +66,34 @@ def plot_real_vs_prediction(
else:
plt.savefig(savefig)
plt.close()
+
+
+def plot_power_distribution(
+ ser: np.array, app: str, bins: int = 20, figsize: tuple = (3, 3)
+):
+ """Plot the histogram of power distribution
+
+ Parameters
+ ----------
+ ser : numpy.array
+ Contains all the power values
+ app : label
+ Name of the appliance
+ bins : int, optional
+ Histogram splits, by default 20
+ figsize : tuple, optional
+ Figure size, by default (3, 3)
+
+ Returns
+ -------
+ matplotlib.figure.Figure
+ matplotlib.axes._subplots.AxesSubplot
+
+ """
+ fig, ax = plt.subplots(figsize=figsize)
+ ax.hist(ser, bins=bins)
+ ax.set_title(app.capitalize().replace("_", " "))
+ ax.set_xlabel("Power (watts)")
+ ax.set_ylabel("Frequency")
+ ax.grid()
+ return fig, ax
From 223a027e0abd7370e21de8daec87943242072cf4 Mon Sep 17 00:00:00 2001
From: daniprec
Date: Thu, 20 May 2021 14:07:51 +0200
Subject: [PATCH 02/47] Threshold returns info when called
---
nilmth/data/threshold.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/nilmth/data/threshold.py b/nilmth/data/threshold.py
index a53e35e..39bd2aa 100644
--- a/nilmth/data/threshold.py
+++ b/nilmth/data/threshold.py
@@ -41,6 +41,10 @@ def __init__(
self._status_fun = self._compute_status
self._initialize_params()
+ def __repr__(self):
+ """This message is returned any time the object is called"""
+ return f"Threshold | Method: {self.method} | Statuses: {self.num_status}"
+
def _initialize_params(self):
"""
Given the method name and list of appliances,
From 98bd9ba673412ea30c7c8aaf9ff1538fb2d78169 Mon Sep 17 00:00:00 2001
From: daniprec
Date: Thu, 20 May 2021 14:08:17 +0200
Subject: [PATCH 03/47] Dataset returns info when called, add datapoints
property
---
nilmth/data/dataset.py | 16 +++++++++++-----
1 file changed, 11 insertions(+), 5 deletions(-)
diff --git a/nilmth/data/dataset.py b/nilmth/data/dataset.py
index 217c69b..8d18ffd 100644
--- a/nilmth/data/dataset.py
+++ b/nilmth/data/dataset.py
@@ -11,7 +11,6 @@
class DataSet(data.Dataset):
files: list = list()
- datapoints: int = 0
appliances: list = list()
num_apps: int = 0
status: list = list()
@@ -50,6 +49,17 @@ def __init__(
f"Dataset received extra kwargs, not used:\n {', '.join(kwargs.keys())}"
)
+ @property
+ def datapoints(self) -> int:
+ return len(self.files)
+
+ def __len__(self):
+ return self.datapoints
+
+ def __repr__(self):
+ """This message is returned any time the object is called"""
+ return f"Dataset | Data points: {self.datapoints} | Input length: {self.length}"
+
@staticmethod
def _open_file(path_file: str) -> pd.DataFrame:
"""Opens a csv as a pandas.DataFrame"""
@@ -85,7 +95,6 @@ def _list_files(self, path_data: str):
files += files_of_building[test_idx:]
# Update the class parameters
self.files = files
- self.datapoints = len(files)
logger.info(f"{self.datapoints} data points found for {self.subset}")
def _get_parameters_from_file(self):
@@ -160,6 +169,3 @@ def __getitem__(self, index: int) -> tuple:
except KeyError:
s = self.power_to_status(y)
return x, y, s
-
- def __len__(self):
- return self.datapoints
From a1390ca6ee4fa293f123ec6e479060a7110571b3 Mon Sep 17 00:00:00 2001
From: daniprec
Date: Thu, 20 May 2021 14:08:41 +0200
Subject: [PATCH 04/47] Dataloader returns info when called, add threshold and
appliances properties
---
nilmth/data/dataloader.py | 23 ++++++++++++++++++-----
1 file changed, 18 insertions(+), 5 deletions(-)
diff --git a/nilmth/data/dataloader.py b/nilmth/data/dataloader.py
index 986b387..c35482c 100644
--- a/nilmth/data/dataloader.py
+++ b/nilmth/data/dataloader.py
@@ -4,6 +4,7 @@
import torch.utils.data as data
from nilmth.data.dataset import DataSet
+from nilmth.data.threshold import Threshold
from nilmth.utils.config import ConfigError
from nilmth.utils.logging import logger
@@ -28,11 +29,23 @@ def __init__(
self.path_threshold = path_threshold
self.compute_thresholds()
+ @property
+ def threshold(self) -> Threshold:
+ return self.dataset.threshold
+
+ @property
+ def appliances(self) -> list:
+ return self.dataset.appliances
+
+ def __repr__(self):
+ """This message is returned any time the object is called"""
+ return f"Dataloader > {self.dataset}"
+
def get_appliance_power_series(self, app: Union[str, int]) -> np.array:
"""Returns the full series of power of an appliance"""
if type(app) == str:
try:
- app_idx = self.dataset.appliances.index(app)
+ app_idx = self.appliances.index(app)
except ValueError:
raise ValueError(f"Appliance not found: {app}")
else:
@@ -48,7 +61,7 @@ def compute_thresholds(self):
"""Compute the thresholds of each appliance"""
# First try to load the thresholds
try:
- self.dataset.threshold.read_config(self.path_threshold)
+ self.threshold.read_config(self.path_threshold)
return
# If not possible, compute them
except ConfigError:
@@ -59,12 +72,12 @@ def compute_thresholds(self):
)
logger.debug("Threshold values not found. Computing them...")
# Loop through each appliance
- for app_idx, app in enumerate(self.dataset.appliances):
+ for app_idx, app in enumerate(self.appliances):
ser = self.get_appliance_power_series(app_idx)
# Concatenate all values and update the threshold
- self.dataset.threshold.update_appliance_threshold(ser, app)
+ self.threshold.update_appliance_threshold(ser, app)
# Write the config file
- self.dataset.threshold.write_config(self.path_threshold)
+ self.threshold.write_config(self.path_threshold)
def next(self):
"""Returns the next data batch"""
From 8f22e365ee0974d63d01129e649b3600f6658158 Mon Sep 17 00:00:00 2001
From: daniprec
Date: Thu, 20 May 2021 14:10:25 +0200
Subject: [PATCH 05/47] Dataset add property status
---
nilmth/data/dataloader.py | 4 ++++
nilmth/data/temporal.py | 4 +---
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/nilmth/data/dataloader.py b/nilmth/data/dataloader.py
index c35482c..f2a955b 100644
--- a/nilmth/data/dataloader.py
+++ b/nilmth/data/dataloader.py
@@ -37,6 +37,10 @@ def threshold(self) -> Threshold:
def appliances(self) -> list:
return self.dataset.appliances
+ @property
+ def status(self):
+ return self.dataset.status
+
def __repr__(self):
"""This message is returned any time the object is called"""
return f"Dataloader > {self.dataset}"
diff --git a/nilmth/data/temporal.py b/nilmth/data/temporal.py
index ff11dd6..0abf9de 100644
--- a/nilmth/data/temporal.py
+++ b/nilmth/data/temporal.py
@@ -44,9 +44,7 @@ def generate_temporal_data(loader: DataLoader, path: str = "data_temp"):
for m in mat:
df = pd.DataFrame(
m,
- columns=["aggregate"]
- + loader.dataset.appliances
- + loader.dataset.status,
+ columns=["aggregate"] + loader.appliances + loader.status,
)
path_file = os.path.join(path, f"{file_num:04}.csv")
df.to_csv(path_file)
From bd90c52a39eba6b94f58d7e8c8057646e0aafa62 Mon Sep 17 00:00:00 2001
From: daniprec
Date: Thu, 20 May 2021 14:14:03 +0200
Subject: [PATCH 06/47] Dataset add properties border and files
---
nilmth/data/dataloader.py | 10 +++++++++-
nilmth/data/dataset.py | 2 +-
nilmth/data/temporal.py | 4 ++--
3 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/nilmth/data/dataloader.py b/nilmth/data/dataloader.py
index f2a955b..f548015 100644
--- a/nilmth/data/dataloader.py
+++ b/nilmth/data/dataloader.py
@@ -38,9 +38,17 @@ def appliances(self) -> list:
return self.dataset.appliances
@property
- def status(self):
+ def status(self) -> list:
return self.dataset.status
+ @property
+ def files(self) -> list:
+ return self.dataset.files
+
+ @property
+ def border(self) -> int:
+ return self.dataset.border
+
def __repr__(self):
"""This message is returned any time the object is called"""
return f"Dataloader > {self.dataset}"
diff --git a/nilmth/data/dataset.py b/nilmth/data/dataset.py
index 8d18ffd..7b99cdc 100644
--- a/nilmth/data/dataset.py
+++ b/nilmth/data/dataset.py
@@ -52,7 +52,7 @@ def __init__(
@property
def datapoints(self) -> int:
return len(self.files)
-
+
def __len__(self):
return self.datapoints
diff --git a/nilmth/data/temporal.py b/nilmth/data/temporal.py
index 0abf9de..ce4815b 100644
--- a/nilmth/data/temporal.py
+++ b/nilmth/data/temporal.py
@@ -22,14 +22,14 @@ def generate_temporal_data(loader: DataLoader, path: str = "data_temp"):
os.mkdir(path)
# Initialize the file number and the list of files
file_num = 0
- files = loader.dataset.files.copy()
+ files = loader.files.copy()
# Iterate through the whole dataloader
for data, target_power, target_status in iter(loader):
data = data.cpu().detach().numpy()
target_power = target_power.cpu().detach().numpy()
target_status = target_status.cpu().detach().numpy()
# Add the border for appliance power and status
- npad = ((0, 0), (loader.dataset.border, loader.dataset.border), (0, 0))
+ npad = ((0, 0), (loader.border, loader.border), (0, 0))
target_power = np.pad(
target_power, pad_width=npad, mode="constant", constant_values=0
)
From fc263f9bf735b1fb705b0bef2a0581612868869e Mon Sep 17 00:00:00 2001
From: daniprec
Date: Thu, 20 May 2021 15:54:27 +0200
Subject: [PATCH 07/47] get_appliance_power_series to get_appliance_series
---
nilmth/data/dataloader.py | 31 ++++++++++++++++++++++++++-----
1 file changed, 26 insertions(+), 5 deletions(-)
diff --git a/nilmth/data/dataloader.py b/nilmth/data/dataloader.py
index f548015..e90811e 100644
--- a/nilmth/data/dataloader.py
+++ b/nilmth/data/dataloader.py
@@ -53,8 +53,23 @@ def __repr__(self):
"""This message is returned any time the object is called"""
return f"Dataloader > {self.dataset}"
- def get_appliance_power_series(self, app: Union[str, int]) -> np.array:
- """Returns the full series of power of an appliance"""
+ def get_appliance_series(
+ self, app: Union[str, int], target: str = "power"
+ ) -> np.array:
+ """Returns the full series of power of an appliance
+
+ Parameters
+ ----------
+ app : str
+ Appliance label
+ target : str, optional
+ Target value (power or status), by default "power"
+
+ Returns
+ -------
+ numpy.array
+
+ """
if type(app) == str:
try:
app_idx = self.appliances.index(app)
@@ -65,8 +80,14 @@ def get_appliance_power_series(self, app: Union[str, int]) -> np.array:
# Initialize list
ser = [0] * self.__len__()
# Loop through the set and extract all the values
- for idx, (_, meters, _) in enumerate(self):
- ser[idx] = meters[:, :, app_idx].flatten()
+ if target.lower() == "power":
+ for idx, (_, meters, _) in enumerate(self):
+ ser[idx] = meters[:, :, app_idx].flatten()
+ elif target.lower() == "status":
+ for idx, (_, _, meters) in enumerate(self):
+ ser[idx] = meters[:, :, app_idx].flatten()
+ else:
+ raise ValueError(f"Target not available: {target}")
return np.concatenate(ser)
def compute_thresholds(self):
@@ -85,7 +106,7 @@ def compute_thresholds(self):
logger.debug("Threshold values not found. Computing them...")
# Loop through each appliance
for app_idx, app in enumerate(self.appliances):
- ser = self.get_appliance_power_series(app_idx)
+ ser = self.get_appliance_series(app_idx)
# Concatenate all values and update the threshold
self.threshold.update_appliance_threshold(ser, app)
# Write the config file
From 5ddb3e9bd56a85046bf6c2ae7581b87177e78814 Mon Sep 17 00:00:00 2001
From: daniprec
Date: Thu, 20 May 2021 16:11:47 +0200
Subject: [PATCH 08/47] get_appliance_series can return reconstructed power
---
nilmth/data/dataloader.py | 31 +++++++++++++++++++++++++------
1 file changed, 25 insertions(+), 6 deletions(-)
diff --git a/nilmth/data/dataloader.py b/nilmth/data/dataloader.py
index e90811e..f19631c 100644
--- a/nilmth/data/dataloader.py
+++ b/nilmth/data/dataloader.py
@@ -37,6 +37,10 @@ def threshold(self) -> Threshold:
def appliances(self) -> list:
return self.dataset.appliances
+ @property
+ def num_apps(self) -> int:
+ return self.dataset.num_apps
+
@property
def status(self) -> list:
return self.dataset.status
@@ -56,14 +60,15 @@ def __repr__(self):
def get_appliance_series(
self, app: Union[str, int], target: str = "power"
) -> np.array:
- """Returns the full series of power of an appliance
+ """Returns the full series of an appliance, being it power, status or
+ reconstructed power
Parameters
----------
app : str
Appliance label
target : str, optional
- Target value (power or status), by default "power"
+ Target value (power, status or reconstructed), by default "power"
Returns
-------
@@ -79,16 +84,30 @@ def get_appliance_series(
app_idx = app
# Initialize list
ser = [0] * self.__len__()
- # Loop through the set and extract all the values
- if target.lower() == "power":
+ # Set target string to lowercase
+ target = target.lower()
+ # Choose the target, then loop through the set and extract all the values
+ # Power
+ if target.startswith("p"):
for idx, (_, meters, _) in enumerate(self):
ser[idx] = meters[:, :, app_idx].flatten()
- elif target.lower() == "status":
+ return np.concatenate(ser)
+ # Status or reconstructed power (taken from status)
+ elif target.startswith("s") | target.startswith("r"):
for idx, (_, _, meters) in enumerate(self):
ser[idx] = meters[:, :, app_idx].flatten()
+ # If status, return it directly
+ if target.startswith("s"):
+ return np.concatenate(ser)
+ # If reconstructed power, compute its value from status
+ else:
+ ser = np.concatenate(ser)
+ ser = np.repeat(
+ np.expand_dims(ser, axis=1), repeats=self.num_apps, axis=1
+ )
+ return self.dataset.status_to_power(ser)[:, app_idx]
else:
raise ValueError(f"Target not available: {target}")
- return np.concatenate(ser)
def compute_thresholds(self):
"""Compute the thresholds of each appliance"""
From 086f4668a602c5ac5b6317ec4be6c1e70a8e914e Mon Sep 17 00:00:00 2001
From: daniprec
Date: Thu, 20 May 2021 17:05:12 +0200
Subject: [PATCH 09/47] Threshold method to set threshold and centroid values
---
nilmth/data/threshold.py | 27 +++++++++++++++++++++++++++
1 file changed, 27 insertions(+)
diff --git a/nilmth/data/threshold.py b/nilmth/data/threshold.py
index 39bd2aa..0514b0a 100644
--- a/nilmth/data/threshold.py
+++ b/nilmth/data/threshold.py
@@ -20,6 +20,7 @@
class Threshold:
+ num_status: int = 2
thresholds: np.array = None # (appliance, status)
centroids: np.array = None # (appliance, status)
use_std: bool = False
@@ -314,3 +315,29 @@ def write_config(self, path_config: str):
config = dict_config
store_config(path_config, config)
logging.debug(f"Config stored at {path_config}\n")
+
+ def set_thresholds_and_centroids(
+ self, thresholds: np.array, centroids: np.array
+ ):
+ """Change threshold and centroid values to given ones
+
+ Parameters
+ ----------
+ thresholds : numpy.array
+ New threshold values
+ centroids : numpy.array
+ New centroid values
+
+ """
+ assert len(thresholds.shape), "Array must have two dimensions"
+ assert (
+ thresholds.shape[0] == self.num_apps
+ ), f"Axis 0 must have length {self.num_apps}"
+ assert (
+ thresholds.shape == centroids.shape
+ ), "Both arrays must have same dimension"
+ self.num_status = thresholds.shape[1]
+ self.thresholds = thresholds
+ self.centroids = centroids
+ self.method = "custom"
+ self._status_fun = self._compute_status
From 510e54fcf187e74d287cc8ea4a3647fde53ed8ed Mon Sep 17 00:00:00 2001
From: daniprec
Date: Thu, 20 May 2021 17:18:29 +0200
Subject: [PATCH 10/47] Change param name length to len_series
---
nilmth/data/dataset.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/nilmth/data/dataset.py b/nilmth/data/dataset.py
index 7b99cdc..11d6703 100644
--- a/nilmth/data/dataset.py
+++ b/nilmth/data/dataset.py
@@ -32,7 +32,7 @@ def __init__(
):
self.subset = subset
self.border = border
- self.length = input_len
+ self.len_series = input_len
self.buildings = {} if buildings is None else buildings[subset]
self.train_size = train_size
self.validation_size = valid_size
@@ -58,7 +58,7 @@ def __len__(self):
def __repr__(self):
"""This message is returned any time the object is called"""
- return f"Dataset | Data points: {self.datapoints} | Input length: {self.length}"
+ return f"Dataset | Data points: {self.datapoints} | Input length: {self.len_series}"
@staticmethod
def _open_file(path_file: str) -> pd.DataFrame:
@@ -105,9 +105,9 @@ def _get_parameters_from_file(self):
self.appliances = sorted(appliances)
self.num_apps = len(appliances)
self.status = [app + "_status" for app in self.appliances]
- self.length = df.shape[0]
+ self.len_series = df.shape[0]
self._idx_start = self.border
- self._idx_end = self.length - self.border
+ self._idx_end = self.len_series - self.border
def power_to_status(self, ser: np.array) -> np.array:
"""Computes the status assigned to each power value
From 3f7dc85163df172acf44e059a5aaede95bfa2299 Mon Sep 17 00:00:00 2001
From: daniprec
Date: Thu, 20 May 2021 17:48:33 +0200
Subject: [PATCH 11/47] Optimize status_to_power method
---
nilmth/data/dataset.py | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/nilmth/data/dataset.py b/nilmth/data/dataset.py
index 11d6703..fed8179 100644
--- a/nilmth/data/dataset.py
+++ b/nilmth/data/dataset.py
@@ -140,10 +140,7 @@ def status_to_power(self, ser: np.array) -> np.array:
"""
# Get power values from status
- power = np.multiply(np.ones(ser.shape), self.threshold.centroids[:, 0])
- power_on = np.multiply(np.ones(ser.shape), self.threshold.centroids[:, 1])
- power[ser == 1] = power_on[ser == 1]
- return power
+ return np.take_along_axis(self.threshold.centroids, ser.T, axis=1).T
def __getitem__(self, index: int) -> tuple:
"""Returns an element of the data loader
From 39589e5b0fa967ccbc74c6c805696d1c6ba9e9e4 Mon Sep 17 00:00:00 2001
From: daniprec
Date: Thu, 20 May 2021 18:02:33 +0200
Subject: [PATCH 12/47] Dataset takes the list of appliances as input
---
nilmth/data/dataset.py | 24 +++++++++++++++++++-----
1 file changed, 19 insertions(+), 5 deletions(-)
diff --git a/nilmth/data/dataset.py b/nilmth/data/dataset.py
index fed8179..56ba9bb 100644
--- a/nilmth/data/dataset.py
+++ b/nilmth/data/dataset.py
@@ -12,13 +12,13 @@
class DataSet(data.Dataset):
files: list = list()
appliances: list = list()
- num_apps: int = 0
status: list = list()
threshold: Threshold = None
def __init__(
self,
path_data: str,
+ appliances: list = None,
subset: str = "train",
input_len: int = 510,
border: int = 15,
@@ -31,6 +31,7 @@ def __init__(
**kwargs,
):
self.subset = subset
+ self.appliances = appliances
self.border = border
self.len_series = input_len
self.buildings = {} if buildings is None else buildings[subset]
@@ -53,12 +54,19 @@ def __init__(
def datapoints(self) -> int:
return len(self.files)
+ @property
+ def num_apps(self) -> int:
+ return len(self.appliances)
+
def __len__(self):
return self.datapoints
def __repr__(self):
"""This message is returned any time the object is called"""
- return f"Dataset | Data points: {self.datapoints} | Input length: {self.len_series}"
+ return (
+ f"Dataset | Data points: {self.datapoints} | "
+ f"Input length: {self.len_series}"
+ )
@staticmethod
def _open_file(path_file: str) -> pd.DataFrame:
@@ -100,10 +108,16 @@ def _list_files(self, path_data: str):
def _get_parameters_from_file(self):
"""Updates class parameters from sample csv file"""
df = self._open_file(self.files[0])
+ # List appliances in file
appliances = [t for t in df.columns if not t.endswith("_status")]
- appliances.remove("aggregate")
- self.appliances = sorted(appliances)
- self.num_apps = len(appliances)
+ # Ensure our list of appliances is contained in the dataset
+ # If we have no list, take the whole dataset
+ if self.appliances is None:
+ self.appliances = appliances
+ else:
+ for app in self.appliances:
+ assert app in appliances, f"Appliance missing in dataset: {app}"
+ # List the status columns
self.status = [app + "_status" for app in self.appliances]
self.len_series = df.shape[0]
self._idx_start = self.border
From 9e9a4ba433c79f2838dd9ed238da2765fb8dff68 Mon Sep 17 00:00:00 2001
From: Daniel Precioso
Date: Tue, 1 Jun 2021 17:08:13 +0200
Subject: [PATCH 13/47] Rename class according to script. Make abstract method
---
nilmth/data/preprocessing.py | 8 +++++---
nilmth/data/ukdale.py | 6 +++---
nilmth/preprocess.py | 4 ++--
3 files changed, 10 insertions(+), 8 deletions(-)
diff --git a/nilmth/data/preprocessing.py b/nilmth/data/preprocessing.py
index 519eccb..18ef06e 100644
--- a/nilmth/data/preprocessing.py
+++ b/nilmth/data/preprocessing.py
@@ -1,5 +1,6 @@
import logging
import os
+from abc import abstractmethod
import pandas as pd
@@ -8,8 +9,8 @@
logging.basicConfig(level=logging.DEBUG, format="%(message)s")
-class PreprocessWrapper:
- dataset: str = "wrapper"
+class Preprocessing:
+ dataset: str = "Wrapper"
def __init__(
self,
@@ -64,9 +65,10 @@ def __init__(
f" {', '.join(kwargs.keys())}\n"
)
+ @abstractmethod
def load_house_meters(self, house: int) -> pd.DataFrame:
"""Placeholder function, this should load the household meters and status"""
- return pd.DataFrame()
+ pass
def store_preprocessed_data(self, path_output: str):
"""Stores preprocessed data in output folder"""
diff --git a/nilmth/data/ukdale.py b/nilmth/data/ukdale.py
index 6604374..a2e6fb8 100644
--- a/nilmth/data/ukdale.py
+++ b/nilmth/data/ukdale.py
@@ -4,16 +4,16 @@
from pandas import Series
from pandas.io.pytables import HDFStore
-from nilmth.data.preprocessing import PreprocessWrapper
+from nilmth.data.preprocessing import Preprocessing
from nilmth.utils.string import APPLIANCE_NAMES, homogenize_string
-class UkdalePreprocess(PreprocessWrapper):
+class Ukdale(Preprocessing):
dataset: str = "ukdale"
datastore: HDFStore = None
def __init__(self, path_h5: str, path_labels: str, **kwargs):
- super(UkdalePreprocess, self).__init__(**kwargs)
+ super(Ukdale, self).__init__(**kwargs)
self._path_h5 = path_h5
self._path_labels = path_labels
# Load the datastore
diff --git a/nilmth/preprocess.py b/nilmth/preprocess.py
index e745c27..b6df9b7 100644
--- a/nilmth/preprocess.py
+++ b/nilmth/preprocess.py
@@ -2,7 +2,7 @@
import typer
-from nilmth.data.ukdale import UkdalePreprocess
+from nilmth.data.ukdale import Ukdale
from nilmth.utils.config import load_config
from nilmth.utils.logging import logger
@@ -13,7 +13,7 @@ def preprocess_ukdale(path_data: str, path_output: str, config: dict):
path_h5 = path_ukdale + ".h5"
- prep = UkdalePreprocess(path_h5, path_ukdale, **config)
+ prep = Ukdale(path_h5, path_ukdale, **config)
prep.store_preprocessed_data(path_output)
From b8492d10c388e0b3d33b28b32965a683d6778a9d Mon Sep 17 00:00:00 2001
From: Daniel Precioso
Date: Tue, 1 Jun 2021 17:18:56 +0200
Subject: [PATCH 14/47] Homogenize label outside ukdale
---
nilmth/data/ukdale.py | 8 +++-----
nilmth/utils/string.py | 6 ++++++
2 files changed, 9 insertions(+), 5 deletions(-)
diff --git a/nilmth/data/ukdale.py b/nilmth/data/ukdale.py
index a2e6fb8..67147bb 100644
--- a/nilmth/data/ukdale.py
+++ b/nilmth/data/ukdale.py
@@ -5,7 +5,7 @@
from pandas.io.pytables import HDFStore
from nilmth.data.preprocessing import Preprocessing
-from nilmth.utils.string import APPLIANCE_NAMES, homogenize_string
+from nilmth.utils.string import homogenize_label
class Ukdale(Preprocessing):
@@ -76,16 +76,14 @@ def _datastore_to_series(self, house: int, label: str) -> pd.Series:
).to_dict()[1]
# Homogenize input label
- label = homogenize_string(label)
- label = APPLIANCE_NAMES.get(label, label)
+ label = homogenize_label(label)
# Series placeholder
s = None
# Iterate through all the existing labels, searching for the input label
for i in labels:
- lab = homogenize_string(labels[i])
- lab = APPLIANCE_NAMES.get(lab, lab)
+ lab = homogenize_label(labels[i])
# When we find the input label, we load the meter records
if lab == label:
s = self._load_meter(house, i)
diff --git a/nilmth/utils/string.py b/nilmth/utils/string.py
index 8e814ec..fc04488 100644
--- a/nilmth/utils/string.py
+++ b/nilmth/utils/string.py
@@ -72,3 +72,9 @@ def homogenize_string(string, remove_dieresis_u=True):
string_low_deaccent = deaccent(
string_low, remove_dieresis_u=remove_dieresis_u)
return string_low_deaccent
+
+
+def homogenize_label(label: str) -> str:
+ """Homogenizes the input label (which is the name of an appliance or meter)"""
+ label = homogenize_string(label)
+ return APPLIANCE_NAMES.get(label, label)
From a170a7274054a65a133424538a816481cfcede93 Mon Sep 17 00:00:00 2001
From: Daniel Precioso
Date: Tue, 1 Jun 2021 17:21:31 +0200
Subject: [PATCH 15/47] Optimize ukdale for loop
---
nilmth/data/ukdale.py | 7 ++-----
1 file changed, 2 insertions(+), 5 deletions(-)
diff --git a/nilmth/data/ukdale.py b/nilmth/data/ukdale.py
index 67147bb..638ba71 100644
--- a/nilmth/data/ukdale.py
+++ b/nilmth/data/ukdale.py
@@ -78,17 +78,14 @@ def _datastore_to_series(self, house: int, label: str) -> pd.Series:
# Homogenize input label
label = homogenize_label(label)
- # Series placeholder
- s = None
-
# Iterate through all the existing labels, searching for the input label
for i in labels:
lab = homogenize_label(labels[i])
# When we find the input label, we load the meter records
if lab == label:
s = self._load_meter(house, i)
-
- if s is None:
+ break
+ else:
raise ValueError(
f"Label {label} not found on house {house}\n"
f"Valid labels are: {list(labels.values())}"
From d5a6345594efc1bcee7dcdad031e56751d9d386d Mon Sep 17 00:00:00 2001
From: daniprec
Date: Wed, 2 Jun 2021 13:04:59 +0200
Subject: [PATCH 16/47] Hierarchical clustering class
---
nilmth/data/clustering.py | 112 ++++++++++++++++++++++++++++++++++++++
1 file changed, 112 insertions(+)
create mode 100644 nilmth/data/clustering.py
diff --git a/nilmth/data/clustering.py b/nilmth/data/clustering.py
new file mode 100644
index 0000000..0d078a1
--- /dev/null
+++ b/nilmth/data/clustering.py
@@ -0,0 +1,112 @@
+import itertools
+
+import matplotlib.pyplot as plt
+import numpy as np
+from scipy.cluster.hierarchy import dendrogram, linkage, fcluster, cophenet
+from scipy.spatial.distance import pdist
+
+from nilmth.utils.plot import plot_power_distribution
+
+
+class HierarchicalClustering:
+ x: np.array = None
+ z: np.array = None
+ thresh: np.array = None
+ centroids: np.array = None
+ dendrogram: dict = None
+
+ def __init__(
+ self, method: str = "average", n_cluster: int = 2, criterion: str = "maxclust"
+ ):
+ self.method = method
+ self.n_cluster = n_cluster
+ self.criterion = criterion
+
+ def perform_clustering(self, ser: np.array, method: str = None):
+ """Performs the actual clustering, using the linkage function
+
+ Returns
+ -------
+ numpy.array
+ Z[i] will tell us which clusters were merged in the i-th iteration
+ """
+ if method is not None:
+ self.method = method
+ # The shape of our X matrix must be (n, m)
+ # n = samples, m = features
+ self.x = np.expand_dims(ser, axis=1)
+ self.z = linkage(self.x, method=self.method)
+
+ @property
+ def cophenet(self):
+ # Cophenet correlation coefficient
+ c, coph_dists = cophenet(self.z, pdist(self.x))
+ return c
+
+ def plot_dendrogram(self, p=6, max_d=None, figsize=(3, 3)):
+ fig, ax = plt.subplots(figsize=figsize)
+ self.dendrogram = dendrogram(
+ self.z,
+ p=p,
+ orientation="right",
+ truncate_mode="lastp",
+ labels=self.x[:, 0],
+ ax=ax,
+ )
+ if max_d is not None:
+ ax.axvline(x=max_d, c="k")
+ return fig, ax
+
+ @property
+ def dendrogram_distance(self):
+ return sorted(set(itertools.chain(*self.dendrogram["dcoord"])), reverse=True)
+
+ def plot_dendrogram_distance(self, figsize=(10, 3)):
+ # Initialize plots
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize)
+ # Dendrogram distance
+ ax1.scatter(
+ range(2, len(self.dendrogram_distance) + 1), self.dendrogram_distance[:-1]
+ )
+ ax1.set_ylabel("Distance")
+ ax1.set_xlabel("Number of clusters")
+ ax1.grid()
+ # Dendrogram distance difference
+ diff = np.divide(
+ -np.diff(self.dendrogram_distance), self.dendrogram_distance[:-1]
+ )
+ ax2.scatter(range(3, len(self.dendrogram_distance) + 1), diff[:-1])
+ ax2.set_ylabel("Gradient")
+ ax2.set_xlabel("Number of clusters")
+ ax2.grid()
+ return fig, (ax1, ax2)
+
+ def compute_thresholds_and_centroids(
+ self, n_cluster: int = None, criterion: str = None, centroid: str = "median"
+ ):
+ if n_cluster is not None:
+ self.n_cluster = n_cluster
+ if criterion is not None:
+ self.criterion = criterion
+ clusters = fcluster(self.z, self.n_cluster, self.criterion)
+ # Get centroids
+ if centroid == "median":
+ fun = np.median
+ elif centroid == "mean":
+ fun = np.mean
+ self.centroids = np.array(
+ sorted([fun(self.x[clusters == (c + 1)]) for c in range(self.n_cluster)])
+ )
+ # Sort clusters by power
+ x_max = sorted(
+ [np.max(self.x[clusters == (c + 1)]) for c in range(self.n_cluster)]
+ )
+ x_min = sorted(
+ [np.min(self.x[clusters == (c + 1)]) for c in range(self.n_cluster)]
+ )
+ self.thresh = np.divide(np.array(x_min[1:]) + np.array(x_max[:-1]), 2)
+
+ def plot_cluster_distribution(self, label="", bins=100):
+ fig, ax = plot_power_distribution(self.x, label, bins=bins)
+ [ax.axvline(t, color="r", linestyle="--") for t in self.thresh]
+ return fig, ax
From 2c853f39bad3d96702f53b547cc9f60dc2b3c841 Mon Sep 17 00:00:00 2001
From: daniprec
Date: Tue, 3 Aug 2021 17:18:44 +0200
Subject: [PATCH 17/47] Ignore ipynb files
---
.gitignore | 1 +
1 file changed, 1 insertion(+)
diff --git a/.gitignore b/.gitignore
index cb7a515..b81b090 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@ notebooks/
plots/
output*/
*.csv
+*.ipynb
# Log
log.out
From 3ca8e417f84dd0352de9a2046f154cbf504201af Mon Sep 17 00:00:00 2001
From: daniprec
Date: Tue, 3 Aug 2021 18:01:49 +0200
Subject: [PATCH 18/47] Docstring
---
README.md | 8 ++--
nilmth/data/clustering.py | 79 +++++++++++++++++++++++++++++++--------
2 files changed, 68 insertions(+), 19 deletions(-)
diff --git a/README.md b/README.md
index 6d415bd..7266014 100644
--- a/README.md
+++ b/README.md
@@ -106,19 +106,19 @@ If you want to use your own set of parameters, duplicate the aforementioned
configuration file and modify the paremeters you want to change (without deleting any
parameter). You can then use that config file with the following command:
- ```
+```
python nilmth/train.py --path_config
```
For more information about the script, run:
- ```
+```
python nilmth/train.py --help
```
Once the models are trained, test them with:
- ```
+```
python nilmth/test.py --path_config
```
@@ -136,7 +136,7 @@ models are stored. Then, the script `train.py` will be called, using each
configuration each. This will store the model weights, which will be used
again during the test phase:
- ```
+```
nohup sh test_sequential.sh > log.out &
```
diff --git a/nilmth/data/clustering.py b/nilmth/data/clustering.py
index 0d078a1..48dd071 100644
--- a/nilmth/data/clustering.py
+++ b/nilmth/data/clustering.py
@@ -1,11 +1,11 @@
import itertools
+from typing import Optional, Tuple
import matplotlib.pyplot as plt
import numpy as np
-from scipy.cluster.hierarchy import dendrogram, linkage, fcluster, cophenet
-from scipy.spatial.distance import pdist
-
from nilmth.utils.plot import plot_power_distribution
+from scipy.cluster.hierarchy import cophenet, dendrogram, fcluster, linkage
+from scipy.spatial.distance import pdist
class HierarchicalClustering:
@@ -22,16 +22,24 @@ def __init__(
self.n_cluster = n_cluster
self.criterion = criterion
- def perform_clustering(self, ser: np.array, method: str = None):
+ def perform_clustering(
+ self, ser: np.array, method: Optional[str] = None
+ ) -> np.array:
"""Performs the actual clustering, using the linkage function
+ Parameters
+ ----------
+ ser : np.array
+ Series of points to group in clusters
+ method : str, optional
+ Clustering method, by default None (takes the one from the class)
+
Returns
-------
- numpy.array
+ np.array
Z[i] will tell us which clusters were merged in the i-th iteration
"""
- if method is not None:
- self.method = method
+ self.method = method if method is not None else self.method
# The shape of our X matrix must be (n, m)
# n = samples, m = features
self.x = np.expand_dims(ser, axis=1)
@@ -43,7 +51,20 @@ def cophenet(self):
c, coph_dists = cophenet(self.z, pdist(self.x))
return c
- def plot_dendrogram(self, p=6, max_d=None, figsize=(3, 3)):
+ def plot_dendrogram(
+ self, p: int = 6, max_d: Optional[float] = None, figsize: Tuple[int] = (3, 3)
+ ):
+ """Plots the dendrogram
+
+ Parameters
+ ----------
+ p : int, optional
+ Last split, by default 6
+ max_d : Optional[float], optional
+ Maximum distance between splits, by default None
+ figsize : Tuple[int], optional
+ Figure size, by default (3, 3)
+ """
fig, ax = plt.subplots(figsize=figsize)
self.dendrogram = dendrogram(
self.z,
@@ -61,7 +82,14 @@ def plot_dendrogram(self, p=6, max_d=None, figsize=(3, 3)):
def dendrogram_distance(self):
return sorted(set(itertools.chain(*self.dendrogram["dcoord"])), reverse=True)
- def plot_dendrogram_distance(self, figsize=(10, 3)):
+ def plot_dendrogram_distance(self, figsize: Tuple[int] = (10, 3)):
+ """Plots the dendrogram distances
+
+ Parameters
+ ----------
+ figsize : Tuple[int], optional
+ Size of the figure, by default (10, 3)
+ """
# Initialize plots
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize)
# Dendrogram distance
@@ -82,12 +110,24 @@ def plot_dendrogram_distance(self, figsize=(10, 3)):
return fig, (ax1, ax2)
def compute_thresholds_and_centroids(
- self, n_cluster: int = None, criterion: str = None, centroid: str = "median"
+ self,
+ n_cluster: Optional[int] = None,
+ criterion: Optional[str] = None,
+ centroid: str = "median",
):
- if n_cluster is not None:
- self.n_cluster = n_cluster
- if criterion is not None:
- self.criterion = criterion
+ """Computes the thresholds and centroids of each group
+
+ Parameters
+ ----------
+ n_cluster : Optional[int], optional
+ Number of clusters, by default None
+ criterion : Optional[str], optional
+ Criterion used to compute the clusters, by default None
+ centroid : str, optional
+ Method to compute the centroids (median or mean), by default "median"
+ """
+ self.n_cluster = n_cluster if n_cluster is not None else self.n_cluster
+ self.criterion = criterion if criterion is not None else self.criterion
clusters = fcluster(self.z, self.n_cluster, self.criterion)
# Get centroids
if centroid == "median":
@@ -106,7 +146,16 @@ def compute_thresholds_and_centroids(
)
self.thresh = np.divide(np.array(x_min[1:]) + np.array(x_max[:-1]), 2)
- def plot_cluster_distribution(self, label="", bins=100):
+ def plot_cluster_distribution(self, label: str = "", bins: int = 100):
+ """Plots the power distribution, and the lines splitting each cluster
+
+ Parameters
+ ----------
+ label : str, optional
+ Label of the distribution, by default ""
+ bins : int, optional
+ Number of bins, by default 100
+ """
fig, ax = plot_power_distribution(self.x, label, bins=bins)
[ax.axvline(t, color="r", linestyle="--") for t in self.thresh]
return fig, ax
From e4207dd8c5407e3c91ed3c9aa3f2fed2745b6ffc Mon Sep 17 00:00:00 2001
From: daniprec
Date: Wed, 4 Aug 2021 12:48:30 +0200
Subject: [PATCH 19/47] Move plot_cluster_distribution to plot
---
nilmth/data/clustering.py | 14 --------------
nilmth/utils/plot.py | 34 +++++++++++++++++++++++++++++++---
2 files changed, 31 insertions(+), 17 deletions(-)
diff --git a/nilmth/data/clustering.py b/nilmth/data/clustering.py
index 48dd071..026fc84 100644
--- a/nilmth/data/clustering.py
+++ b/nilmth/data/clustering.py
@@ -145,17 +145,3 @@ def compute_thresholds_and_centroids(
[np.min(self.x[clusters == (c + 1)]) for c in range(self.n_cluster)]
)
self.thresh = np.divide(np.array(x_min[1:]) + np.array(x_max[:-1]), 2)
-
- def plot_cluster_distribution(self, label: str = "", bins: int = 100):
- """Plots the power distribution, and the lines splitting each cluster
-
- Parameters
- ----------
- label : str, optional
- Label of the distribution, by default ""
- bins : int, optional
- Number of bins, by default 100
- """
- fig, ax = plot_power_distribution(self.x, label, bins=bins)
- [ax.axvline(t, color="r", linestyle="--") for t in self.thresh]
- return fig, ax
diff --git a/nilmth/utils/plot.py b/nilmth/utils/plot.py
index 584b0bf..999a690 100644
--- a/nilmth/utils/plot.py
+++ b/nilmth/utils/plot.py
@@ -1,3 +1,4 @@
+from typing import Iterable
import numpy as np
from matplotlib import pyplot as plt
@@ -69,7 +70,7 @@ def plot_real_vs_prediction(
def plot_power_distribution(
- ser: np.array, app: str, bins: int = 20, figsize: tuple = (3, 3)
+ ser: np.array, app: str = "", bins: int = 20, figsize: tuple = (3, 3)
):
"""Plot the histogram of power distribution
@@ -77,8 +78,8 @@ def plot_power_distribution(
----------
ser : numpy.array
Contains all the power values
- app : label
- Name of the appliance
+ app : str, optional
+ Name of the appliance, by default ""
bins : int, optional
Histogram splits, by default 20
figsize : tuple, optional
@@ -97,3 +98,30 @@ def plot_power_distribution(
ax.set_ylabel("Frequency")
ax.grid()
return fig, ax
+
+
+def plot_cluster_distribution(
+ ser: np.array,
+ thresh: Iterable[float],
+ app: str = "",
+ bins: int = 20,
+ figsize: tuple = (3, 3),
+):
+ """Plots the power distribution, and the lines splitting each cluster
+
+ Parameters
+ ----------
+ ser : numpy.array
+ Contains all the power values
+ thresh : Iterable[float]
+ Contains all the threshold values
+ app : str, optional
+ Name of the appliance, by default ""
+ bins : int, optional
+ Histogram splits, by default 20
+ figsize : tuple, optional
+ Figure size, by default (3, 3)
+ """
+ fig, ax = plot_power_distribution(ser, app=app, bins=bins, figsize=figsize)
+ [ax.axvline(t, color="r", linestyle="--") for t in thresh]
+ return fig, ax
From b5be502c85aa433c8aa4af7d46bd75467af3c516 Mon Sep 17 00:00:00 2001
From: daniprec
Date: Wed, 4 Aug 2021 12:48:44 +0200
Subject: [PATCH 20/47] Script to compute hie clust
---
nilmth/hierarchical_clustering.py | 101 ++++++++++++++++++++++++++++++
1 file changed, 101 insertions(+)
create mode 100644 nilmth/hierarchical_clustering.py
diff --git a/nilmth/hierarchical_clustering.py b/nilmth/hierarchical_clustering.py
new file mode 100644
index 0000000..7dcc1ee
--- /dev/null
+++ b/nilmth/hierarchical_clustering.py
@@ -0,0 +1,101 @@
+import os
+
+import matplotlib.pyplot as plt
+import numpy as np
+import typer
+
+from nilmth.data.clustering import HierarchicalClustering
+from nilmth.data.dataloader import DataLoader
+from nilmth.utils.config import load_config
+from nilmth.utils.scores import regression_scores_dict
+
+LIST_APPLIANCES = ["dish_washer", "fridge", "washing_machine"]
+LIST_CLUSTER = [2, 3, 4, 5, 6]
+LIST_LINKAGE = [
+ "average",
+ "weighted",
+ "centroid",
+ "median",
+ "ward", # Ward variance minimization algorithm
+]
+
+
+def main(
+ limit: int = 20000,
+ path_data: str = "data-prep",
+ path_threshold: str = "threshold.toml",
+ path_config: str = "nilmth/config.toml",
+ path_output: str = "outputs/hieclust",
+):
+
+ # Read config file
+ config = load_config(path_config, "model")
+
+ # Create output path
+ if not os.path.exists(path_output):
+ os.mkdir(path_output)
+
+ for app in LIST_APPLIANCES:
+ config["appliances"] = [app]
+
+ # Prepare data loader with train data
+ dl = DataLoader(
+ path_data=path_data,
+ subset="train",
+ shuffle=False,
+ path_threshold=path_threshold,
+ **config,
+ )
+
+ # Take an appliance series
+ ser = dl.get_appliance_series(app)[:limit]
+ # Name
+ appliance = app.capitalize().replace("_", " ")
+
+ for method in LIST_LINKAGE:
+ # Clustering
+ hie = HierarchicalClustering()
+ hie.perform_clustering(ser, method=method)
+ # Initialize the list of intrinsic error
+ # per number of clusters
+ intr_error = [0] * len(LIST_CLUSTER)
+ # Compute thresholds per number of clusters
+ for idx, n_cluster in enumerate(LIST_CLUSTER):
+ hie.compute_thresholds_and_centroids(n_cluster=n_cluster)
+ # Update thresholds and centroids
+ thresh = np.insert(np.expand_dims(hie.thresh, axis=0), 0, 0, axis=1)
+ centroids = np.expand_dims(hie.centroids, axis=0)
+ dl.threshold.set_thresholds_and_centroids(thresh, centroids)
+ # Create the dictionary of power series
+ power = np.expand_dims(ser, axis=1)
+ sta = dl.dataset.power_to_status(power)
+ recon = dl.dataset.status_to_power(sta)
+ dict_app = {appliance: {"power": power, "power_pred": recon}}
+ # Compute the scores
+ dict_scores = regression_scores_dict(dict_app)
+ intr_error[idx] = dict_scores[appliance]["nde"]
+ # Initialize plots
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
+ fig.suptitle(f"{appliance}, Linkage: {method}")
+ # Plot intrinsic error
+ ax1.plot(LIST_CLUSTER, intr_error, ".--")
+ ax1.set_title(f"Intrinsic Error")
+ ax1.set_ylabel("NDE")
+ ax1.set_xlabel("Number of status")
+ ax1.grid()
+ # Plot proportional reduction
+ rel_error = -100 * np.divide(np.diff(intr_error), intr_error[:-1])
+ ax2.plot(LIST_CLUSTER[1:], rel_error, ".--")
+ ax2.set_title("Reduction of Intrinsic Error")
+ ax2.set_ylabel("Reduction (%)")
+ ax2.set_xlabel("Number of status")
+ ax2.set_ylim(0, 100)
+ ax2.grid()
+ # Save and close the figure
+ path_fig = os.path.join(path_output, f"{app}_{method}.png")
+ fig.savefig(path_fig)
+ plt.close(fig)
+
+
+if __name__ == "__main__":
+ typer.run(main)
From e26a0bfd0be85c9494a798530d914c703f302474 Mon Sep 17 00:00:00 2001
From: daniprec
Date: Wed, 4 Aug 2021 13:18:45 +0200
Subject: [PATCH 21/47] Move plot to hieclust script
---
nilmth/hierarchical_clustering.py | 88 +++++++++++++++++++++++++------
nilmth/utils/plot.py | 28 ----------
2 files changed, 72 insertions(+), 44 deletions(-)
diff --git a/nilmth/hierarchical_clustering.py b/nilmth/hierarchical_clustering.py
index 7dcc1ee..13964ec 100644
--- a/nilmth/hierarchical_clustering.py
+++ b/nilmth/hierarchical_clustering.py
@@ -1,8 +1,11 @@
import os
+from typing import Iterable
import matplotlib.pyplot as plt
import numpy as np
import typer
+from matplotlib import pyplot as plt
+from matplotlib.axes import Axes
from nilmth.data.clustering import HierarchicalClustering
from nilmth.data.dataloader import DataLoader
@@ -20,6 +23,70 @@
]
+def plot_intrinsic_error(intr_error: Iterable[float], ax: Axes):
+ """Plots the intrinsic error depending on the number of splits
+
+ Parameters
+ ----------
+ intr_error : Iterable[float]
+ List of intrinsic error values
+ ax : Axes
+ Axes where the graph is plotted
+ """
+ ax.plot(LIST_CLUSTER, intr_error, ".--")
+ ax.set_ylabel("Intrinsic Error (NDE)")
+ ax.set_xlabel("Number of status")
+ ax.grid()
+
+
+def plot_error_reduction(intr_error: Iterable[float], ax: Axes):
+ """Plots the intrinsic error reduction depending on the number of splits
+
+ Parameters
+ ----------
+ intr_error : Iterable[float]
+ List of intrinsic error values
+ ax : Axes
+ Axes where the graph is plotted
+ """
+ rel_error = -100 * np.divide(np.diff(intr_error), intr_error[:-1])
+ ax.plot(LIST_CLUSTER[1:], rel_error, ".--")
+ ax.set_ylabel("Reduction of Intrinsic Error (%)")
+ ax.set_xlabel("Number of status")
+ ax.set_ylim(0, 100)
+ ax.grid()
+
+
+def plot_cluster_distribution(
+ ser: np.array,
+ thresh: Iterable[float],
+ ax: Axes,
+ app: str = "",
+ bins: int = 100,
+):
+ """Plots the power distribution, and the lines splitting each cluster
+
+ Parameters
+ ----------
+ ser : numpy.array
+ Contains all the power values
+ thresh : Iterable[float]
+ Contains all the threshold values
+ ax : Axes
+ Axes where the graph is plotted
+ app : str, optional
+ Name of the appliance, by default ""
+ bins : int, optional
+ Histogram splits, by default 100
+ """
+ ax.hist(ser, bins=bins)
+ ax.set_title(app.capitalize().replace("_", " "))
+ ax.set_xlabel("Power (watts)")
+ ax.set_ylabel("Frequency")
+ ax.grid()
+ [ax.axvline(t, color="r", linestyle="--") for t in thresh]
+
+
def main(
limit: int = 20000,
path_data: str = "data-prep",
@@ -27,7 +94,6 @@ def main(
path_config: str = "nilmth/config.toml",
path_output: str = "outputs/hieclust",
):
-
# Read config file
config = load_config(path_config, "model")
@@ -75,22 +141,12 @@ def main(
dict_scores = regression_scores_dict(dict_app)
intr_error[idx] = dict_scores[appliance]["nde"]
# Initialize plots
- fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
+ fig, axis = plt.subplots(2, 2, figsize=(12, 8))
fig.suptitle(f"{appliance}, Linkage: {method}")
- # Plot intrinsic error
- ax1.plot(LIST_CLUSTER, intr_error, ".--")
- ax1.set_title(f"Intrinsic Error")
- ax1.set_ylabel("NDE")
- ax1.set_xlabel("Number of status")
- ax1.grid()
- # Plot proportional reduction
- rel_error = -100 * np.divide(np.diff(intr_error), intr_error[:-1])
- ax2.plot(LIST_CLUSTER[1:], rel_error, ".--")
- ax2.set_title("Reduction of Intrinsic Error")
- ax2.set_ylabel("Reduction (%)")
- ax2.set_xlabel("Number of status")
- ax2.set_ylim(0, 100)
- ax2.grid()
+ # Plots
+ plot_cluster_distribution(hie.x, hie.thresh, axis[0, 0])
+ plot_intrinsic_error(intr_error, axis[1, 0])
+ plot_error_reduction(intr_error, axis[1, 1])
# Save and close the figure
path_fig = os.path.join(path_output, f"{app}_{method}.png")
fig.savefig(path_fig)
diff --git a/nilmth/utils/plot.py b/nilmth/utils/plot.py
index 999a690..9a120a4 100644
--- a/nilmth/utils/plot.py
+++ b/nilmth/utils/plot.py
@@ -1,4 +1,3 @@
-from typing import Iterable
import numpy as np
from matplotlib import pyplot as plt
@@ -98,30 +97,3 @@ def plot_power_distribution(
ax.set_ylabel("Frequency")
ax.grid()
return fig, ax
-
-
-def plot_cluster_distribution(
- ser: np.array,
- thresh: Iterable[float],
- app: str = "",
- bins: int = 20,
- figsize: tuple = (3, 3),
-):
- """Plots the power distribution, and the lines splitting each cluster
-
- Parameters
- ----------
- ser : numpy.array
- Contains all the power values
- thresh : Iterable[float]
- Contains all the threshold values
- app : str, optional
- Name of the appliance, by default ""
- bins : int, optional
- Histogram splits, by default 20
- figsize : tuple, optional
- Figure size, by default (3, 3)
- """
- fig, ax = plot_power_distribution(ser, app=app, bins=bins, figsize=figsize)
- [ax.axvline(t, color="r", linestyle="--") for t in thresh]
- return fig, ax
From ff1b687999acc2b432e96a2d6af267468be04dbd Mon Sep 17 00:00:00 2001
From: daniprec
Date: Wed, 4 Aug 2021 13:39:48 +0200
Subject: [PATCH 22/47] Improve plots and add docstring
---
nilmth/hierarchical_clustering.py | 33 ++++++++++++++++++++++++++-----
1 file changed, 28 insertions(+), 5 deletions(-)
diff --git a/nilmth/hierarchical_clustering.py b/nilmth/hierarchical_clustering.py
index 13964ec..76a7002 100644
--- a/nilmth/hierarchical_clustering.py
+++ b/nilmth/hierarchical_clustering.py
@@ -79,12 +79,14 @@ def plot_cluster_distribution(
bins : int, optional
Histogram splits, by default 100
"""
- ax.hist(ser, bins=bins)
+ y, x, _ = ax.hist(ser, bins=bins)
ax.set_title(app.capitalize().replace("_", " "))
ax.set_xlabel("Power (watts)")
ax.set_ylabel("Frequency")
ax.grid()
- [ax.axvline(t, color="r", linestyle="--") for t in thresh]
+ for idx, t in enumerate(thresh):
+ ax.axvline(t, color="r", linestyle="--")
+ ax.text(t + 0.01 * x.max(), y.max(), idx, rotation=0, color="r")
def main(
@@ -94,6 +96,23 @@ def main(
path_config: str = "nilmth/config.toml",
path_output: str = "outputs/hieclust",
):
+ """Performs the hierarchical clustering on the given list of appliances,
+ testing different linkaged methods and number of splits. For each combination,
+ outputs an image with several informative graphs.
+
+ Parameters
+ ----------
+ limit : int, optional
+ Number of data points to use, by default 20000
+ path_data : str, optional
+ Path to the preprocessed data folder, by default "data-prep"
+ path_threshold : str, optional
+ Path to the threshold configuration, by default "threshold.toml"
+ path_config : str, optional
+ Path to the config file, by default "nilmth/config.toml"
+ path_output : str, optional
+ Path to the outputs folder, by default "outputs/hieclust"
+ """
# Read config file
config = load_config(path_config, "model")
@@ -122,9 +141,10 @@ def main(
# Clustering
hie = HierarchicalClustering()
hie.perform_clustering(ser, method=method)
- # Initialize the list of intrinsic error
- # per number of clusters
+ # Initialize the list of intrinsic error per number of clusters
intr_error = [0] * len(LIST_CLUSTER)
+ # Initialize the empty list of thresholds (sorted)
+ thresh_sorted = []
# Compute thresholds per number of clusters
for idx, n_cluster in enumerate(LIST_CLUSTER):
hie.compute_thresholds_and_centroids(n_cluster=n_cluster)
@@ -140,11 +160,14 @@ def main(
# Compute the scores
dict_scores = regression_scores_dict(dict_app)
intr_error[idx] = dict_scores[appliance]["nde"]
+ # Update the sorted list of thresholds
+ thresh = list(set(hie.thresh) - set(thresh_sorted))
+ thresh_sorted.append(thresh[0])
# Initialize plots
fig, axis = plt.subplots(2, 2, figsize=(12, 8))
fig.suptitle(f"{appliance}, Linkage: {method}")
# Plots
- plot_cluster_distribution(hie.x, hie.thresh, axis[0, 0])
+ plot_cluster_distribution(hie.x, thresh_sorted, axis[0, 0])
plot_intrinsic_error(intr_error, axis[1, 0])
plot_error_reduction(intr_error, axis[1, 1])
# Save and close the figure
From 8fc1470381bd88e9888c06363337a549da1b5a50 Mon Sep 17 00:00:00 2001
From: daniprec
Date: Fri, 3 Sep 2021 13:18:43 +0200
Subject: [PATCH 23/47] Update README
---
README.md | 107 ++++++++++++++++++++++++++++++++++++++++++------
images/logo.png | Bin 0 -> 108608 bytes
2 files changed, 94 insertions(+), 13 deletions(-)
create mode 100644 images/logo.png
diff --git a/README.md b/README.md
index 6d415bd..c0d7bcc 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,66 @@
-# NILM: classification VS regression
+
+
+
+
+[![Contributors][contributors-shield]][contributors-url]
+[![Forks][forks-shield]][forks-url]
+[![Stargazers][stars-shield]][stars-url]
+[![Issues][issues-shield]][issues-url]
+[![LinkedIn][linkedin-shield]][linkedin-url]
+
+
+
+
+
+
+
+
+
NILM: classification VS regression
+
+
+
+
+
+ Table of Contents
+
+ -
+ About The Project
+
+ -
+ Getting Started
+
+
+ -
+ Data
+
+
+
+ -
+ Train
+
+
+ - Publications
+ - Contact
+ - Acknowledgements
+
+
+
+## About the project
Non-Intrusive Load Monitoring (NILM) aims to predict the status
or consumption of domestic appliances in a household only by knowing
@@ -14,10 +76,10 @@ deep learning state-of-the-art architectures on both the regression and
classification problems, introducing criteria to select the most convenient
thresholding method.
-Source: [see publications](#publications)
+## Getting started
+### Create the Environment
-## Set up
-### Create the environment using Conda
+To create the environment using Conda:
1. Install miniconda
@@ -122,7 +184,7 @@ Once the models are trained, test them with:
python nilmth/test.py --path_config
```
-#### Reproduce paper
+### Reproduce the Paper
To reproduce the results shown in [our paper](#publications), activate the
environment and then run:
@@ -140,7 +202,7 @@ models are stored. Then, the script `train.py` will be called, using each
nohup sh test_sequential.sh > log.out &
```
-### Thresholding methods
+### Thresholding Methods
There are three threshold methods available. Read [our paper](#publications)
to understand how each threshold works.
@@ -151,13 +213,32 @@ to understand how each threshold works.
## Publications
-[NILM as a regression versus classification problem:
+* [NILM as a regression versus classification problem:
the importance of thresholding](https://www.researchgate.net/project/Non-Intrusive-Load-Monitoring-6)
-## Contact information
+## Contact
-Author: Daniel Precioso, PhD student at Universidad de Cádiz
-- Email: daniel.precioso@uca.es
-- [Github](https://github.com/daniprec)
-- [LinkedIn](https://www.linkedin.com/in/daniel-precioso-garcelan/)
-- [ResearchGate](https://www.researchgate.net/profile/Daniel_Precioso_Garcelan)
+Daniel Precioso - [daniprec](https://github.com/daniprec) - daniel.precioso@uca.es
+
+Project link: [https://github.com/UCA-Datalab/nilm-thresholding](https://github.com/UCA-Datalab/nilm-thresholding)
+
+ResearhGate link: [https://www.researchgate.net/project/NILM-classification-VS-regression](https://www.researchgate.net/project/NILM-classification-VS-regression)
+
+## Acknowledgements
+
+* [UCA DataLab](http://datalab.uca.es/)
+* [David Gómez-Ullate](https://www.linkedin.com/in/david-g%C3%B3mez-ullate-oteiza-87a820b/?originalSubdomain=en)
+
+
+
+
+[contributors-shield]: https://img.shields.io/github/contributors/UCA-Datalab/nilm-thresholding.svg?style=for-the-badge
+[contributors-url]: https://github.com/UCA-Datalab/nilm-thresholding/graphs/contributors
+[forks-shield]: https://img.shields.io/github/forks/UCA-Datalab/nilm-thresholding.svg?style=for-the-badge
+[forks-url]: https://github.com/UCA-Datalab/nilm-thresholding/network/members
+[stars-shield]: https://img.shields.io/github/stars/UCA-Datalab/nilm-thresholding.svg?style=for-the-badge
+[stars-url]: https://github.com/UCA-Datalab/nilm-thresholding/stargazers
+[issues-shield]: https://img.shields.io/github/issues/UCA-Datalab/nilm-thresholding.svg?style=for-the-badge
+[issues-url]: https://github.com/UCA-Datalab/nilm-thresholding/issues
+[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=for-the-badge&logo=linkedin&colorB=555
+[linkedin-url]: https://www.linkedin.com/in/daniel-precioso-garcelan/
\ No newline at end of file
diff --git a/images/logo.png b/images/logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..07dbbaff4d7aa56dbefd08c6af8389fc60a98976
GIT binary patch
literal 108608
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