From 4dc5573dbf88ce93d1669f049e6bd89b62e70580 Mon Sep 17 00:00:00 2001 From: Theodore-Chatziioannou Date: Wed, 17 May 2023 17:30:19 +0000 Subject: [PATCH 01/14] choice module --- examples/15_Advanced_Choice.ipynb | 286 ++++++++++++++++++++++++++++++ pam/planner/choice.py | 45 +++++ pam/planner/od.py | 74 ++++++++ pam/planner/zones.py | 14 ++ tests/test_22_planner_od.py | 58 ++++++ tests/test_23_planner_zones.py | 29 +++ tests/test_24_planner_choice.py | 38 ++++ 7 files changed, 544 insertions(+) create mode 100644 examples/15_Advanced_Choice.ipynb create mode 100644 pam/planner/choice.py create mode 100644 pam/planner/od.py create mode 100644 pam/planner/zones.py create mode 100644 tests/test_22_planner_od.py create mode 100644 tests/test_23_planner_zones.py create mode 100644 tests/test_24_planner_choice.py diff --git a/examples/15_Advanced_Choice.ipynb b/examples/15_Advanced_Choice.ipynb new file mode 100644 index 00000000..0a728260 --- /dev/null +++ b/examples/15_Advanced_Choice.ipynb @@ -0,0 +1,286 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "82e63bf7", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "from pam.read import read_matsim\n", + "from pam.planner import choice\n", + "from pam.operations.cropping import link_population\n", + "from pam.planner.od import OD, Labels\n", + "import numpy as np\n", + "import os" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "4154e47e", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "9542e3c7", + "metadata": {}, + "outputs": [], + "source": [ + "population = read_matsim(os.path.join('..', 'tests', 'test_data', 'test_matsim_plansv12.xml'))\n", + "link_population(population)\n", + "for hid, pid, person in population.people():\n", + " for act in person.activities:\n", + " act.location.area = 'a' " + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "3081485c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
jobsschools
zone
a1003
b2001
\n", + "
" + ], + "text/plain": [ + " jobs schools\n", + "zone \n", + "a 100 3\n", + "b 200 1" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "data_zones = pd.DataFrame(\n", + " {\n", + " 'zone': ['a', 'b'],\n", + " 'jobs': [100, 200],\n", + " 'schools': [3, 1]\n", + " }\n", + ").set_index('zone')\n", + "data_zones" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "51c0dc76", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array([[[[20, 40],\n", + " [40, 20]],\n", + "\n", + " [[ 5, 8],\n", + " [ 8, 5]]],\n", + "\n", + "\n", + " [[[30, 45],\n", + " [45, 30]],\n", + "\n", + " [[ 5, 8],\n", + " [ 8, 5]]]])" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "data_od = np.array(\n", + " [\n", + " [\n", + " [[20, 40], [40, 20]],\n", + " [[5, 8], [8, 5]]\n", + " ],\n", + " [\n", + " [[30, 45], [45, 30]],\n", + " [[5, 8], [8, 5]]\n", + " ]\n", + " ]\n", + ")\n", + "\n", + "labels = {\n", + " 'mode': ['car', 'bus'],\n", + " 'vars': ['time', 'distance'],\n", + " 'origin_zones': ['a', 'b'],\n", + " 'destination_zones': ['a', 'b']\n", + "}\n", + "\n", + "\n", + "od = OD(\n", + " data=data_od,\n", + " labels=labels\n", + ")\n", + "\n", + "od.data" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "760e9d2d", + "metadata": {}, + "outputs": [], + "source": [ + "planner = choice.ChoiceMNL(population, od, data_zones)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "da1be738", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " pid hid seq home_loc act_loc \n", + "0 chris chris 1 a a \\\n", + "1 fatema fatema 1 a a \n", + "2 fred fred 3 a a \n", + "3 gerry gerry 3 a a \n", + "4 nick nick 1 a a \n", + "\n", + " act \n", + "0 Activity(act:work, location:POINT (10100 0), t... \n", + "1 Activity(act:work, location:POINT (10100 0), t... \n", + "2 Activity(act:work, location:POINT (10100 0), t... \n", + "3 Activity(act:work, location:POINT (10100 0), t... \n", + "4 Activity(act:work, location:POINT (0 10000), t... \n" + ] + }, + { + "data": { + "text/plain": [ + "array([[1. , 1. , 1. , 1. ],\n", + " [6. , 3.5 , 4.33333333, 3.22222222],\n", + " [6. , 3.5 , 4.33333333, 3.22222222],\n", + " [6. , 3.5 , 4.33333333, 3.22222222],\n", + " [1. , 1. , 1. , 1. ]])" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# get the choice set, home-based work\n", + "scope_act = 'work'\n", + "var = 'time'\n", + "asc = 1\n", + "beta_time = [1, 2] # one beta for each mode\n", + "\n", + "u = \"\"\" \\\n", + " asc + (1 * (person.attributes['subpopulation']=='poor')) * \\\n", + " (zones['jobs'].values / (beta_time * od[:, 'time', person.home.area]))\n", + "\"\"\"\n", + "index_set = []\n", + "choice_set = []\n", + "od = planner.od\n", + "zones = planner.zones\n", + "for hid, hh in planner.population:\n", + " for pid, person in hh:\n", + " for i, act in enumerate(person.activities):\n", + " if act.act == scope_act: #TODO: scope of function + mandatory locations\n", + " c = {\n", + " 'pid': pid,\n", + " 'hid': hid,\n", + " 'seq': i,\n", + " 'home_loc': person.home.area,\n", + " 'act_loc': act.location.area,\n", + " 'act': act\n", + " }\n", + " p = eval(u)\n", + " p = p.flatten()\n", + " choice_set.append(p)\n", + " index_set.append(c)\n", + " \n", + "index_set = pd.DataFrame(index_set)\n", + "choice_set = np.array(choice_set)\n", + "print(index_set)\n", + "choice_set" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pam/planner/choice.py b/pam/planner/choice.py new file mode 100644 index 00000000..b838e753 --- /dev/null +++ b/pam/planner/choice.py @@ -0,0 +1,45 @@ +""" +Choice models for activity synthesis +""" +from typing import Optional +import pandas as pd +from pam.planner.od import OD +from pam.core import Population +from pam.operations.cropping import link_population +from copy import deepcopy + +class ChoiceModel: + def __init__( + self, + population: Population, + od: OD, + zones: pd.DataFrame + ) -> None: + """ + Choice model interface. + + :param population: A PAM population. + :param od: An object holding origin-destination. + :param zones: Zone-level data. + """ + self.population = population + link_population(self.population) + self.od = od + self.zones = zones.loc[od.labels.destination_zones].copy() + + def configure(): + raise NotImplementedError + + def apply(): + raise NotImplementedError + + +class ChoiceMNL(ChoiceModel): + """ + Implements a Multinomial Logit Choice model + """ + def configure(): + pass + + def apply(): + pass diff --git a/pam/planner/od.py b/pam/planner/od.py new file mode 100644 index 00000000..10d58b1b --- /dev/null +++ b/pam/planner/od.py @@ -0,0 +1,74 @@ +""" +Manages origin-destination data required by the planner module. +""" +import numpy as np +from typing import Union, Optional, List, NamedTuple + + +class Labels(NamedTuple): + """ Data labels for the """ + mode: List + vars: List + origin_zones: List + destination_zones: List + + +class OD: + """ + Holds origin-destination matrices for a number of modes and variables. + """ + dimensions = ['mode', 'variable', 'origin', 'destination'] + + def __init__( + self, + data: np.ndarray, + labels: Union[Labels, List, dict] + ) -> None: + """ + :param data: A multi-dimensional numpy array of the origin-destination data. + - First dimension: mode (ie car, bus, etc) + - Second dimension: variable (ie travel time, distance, etc) + - Third dimension: origin zone + - Fourth dimension: destination zone + """ + self.data = data + self.labels = self.parse_labels(labels) + self.data_checks() + + def data_checks(self): + """ + Check the integrity of input data and labels. + """ + assert self.data.ndim == 4, \ + "The number of matrix dimensions should be 4 (mode, variable, origin, destination)" + for i, (key, labels) in enumerate(zip(self.labels._fields, self.labels)): + assert len(labels) == self.data.shape[i], \ + f"The number of {key} labels should match the first dimension of the OD dataset" + + @staticmethod + def parse_labels(labels: Union[Labels, List, dict]) -> Labels: + """ + Parse labels as a named tuple. + """ + if not isinstance(labels, Labels): + if isinstance(labels, list): + return Labels(*labels) + elif isinstance(labels, dict): + return Labels(**labels) + else: + raise ValueError('Please provide a valid label type') + return labels + + def __getitem__(self, args): + _args = args if isinstance(args, tuple) else tuple([args]) + _args_encoded = tuple() + for i, (arg, labels) in enumerate(zip(_args, self.labels)): + if arg == slice(None) or isinstance(arg, int): + _args_encoded += (arg,) + elif arg in labels: + _args_encoded += (labels.index(arg),) + else: + raise IndexError(f'Invalid slice value {arg}') + + return self.data.__getitem__(_args_encoded) + diff --git a/pam/planner/zones.py b/pam/planner/zones.py new file mode 100644 index 00000000..38f1913c --- /dev/null +++ b/pam/planner/zones.py @@ -0,0 +1,14 @@ +""" +Manages zone-level data required by the planner module. +""" + + +class Zones: + def __init__( + self, + data + ) -> None: + """ + :param data: A dataframe with variables as columns and the zone as index + """ + self.data = data diff --git a/tests/test_22_planner_od.py b/tests/test_22_planner_od.py new file mode 100644 index 00000000..03a2cd91 --- /dev/null +++ b/tests/test_22_planner_od.py @@ -0,0 +1,58 @@ +import pytest +from pam.planner.od import OD, Labels +import pandas as pd +import numpy as np +from copy import deepcopy + + +@pytest.fixture +def data_od(): + matrices = np.array( + [ + [ + [[20, 40], [40, 20]], + [[5, 8], [8, 5]] + ], + [ + [[30, 45], [45, 30]], + [[5, 8], [8, 5]] + ] + ] + ) + return matrices + + +@pytest.fixture +def labels(): + labels = { + 'mode': ['car', 'bus'], + 'vars': ['time', 'distance'], + 'origin_zones': ['a', 'b'], + 'destination_zones': ['a', 'b'] + } + return labels + + +@pytest.fixture +def od(data_od, labels): + od = OD( + data=data_od, + labels=labels + ) + return od + + +def test_label_type_is_parsed_correctly(labels): + assert type(OD.parse_labels(labels)) is Labels + assert type(OD.parse_labels(list(labels.values()))) is Labels + + +def test_inconsistent_labels_raise_error(data_od, labels): + _labels = deepcopy(labels) + for k, v in _labels.items(): + v.pop() + with pytest.raises(AssertionError): + OD( + data=data_od, + labels=_labels + ) diff --git a/tests/test_23_planner_zones.py b/tests/test_23_planner_zones.py new file mode 100644 index 00000000..32e81f04 --- /dev/null +++ b/tests/test_23_planner_zones.py @@ -0,0 +1,29 @@ +import pytest +from pam.planner.zones import Zones +import pandas as pd +import numpy as np + + +@pytest.fixture +def data_zones(): + df = pd.DataFrame( + { + 'zone': ['a', 'b'], + 'jobs': [100, 200], + 'schools': [3, 1] + } + ).set_index('zone') + return df + + +def test_get_zone_data(data_zones): + np.testing.assert_equal(data_zones.loc['b'].values, np.array([200, 1])) + + +def test_get_variable_data(data_zones): + np.testing.assert_equal(data_zones['jobs'], np.array([100, 200])) + + +def test_get_values(data_zones): + assert data_zones.loc['b', 'jobs'] == 200 + assert data_zones.loc['b']['jobs'] == 200 diff --git a/tests/test_24_planner_choice.py b/tests/test_24_planner_choice.py new file mode 100644 index 00000000..6f88d494 --- /dev/null +++ b/tests/test_24_planner_choice.py @@ -0,0 +1,38 @@ +import pytest +from tests.test_22_planner_od import data_od, labels, od +from tests.test_23_planner_zones import data_zones +from pam.planner.choice import ChoiceModel, ChoiceMNL +import os +from pam.read import read_matsim +from pam.planner.od import OD + +test_plans = os.path.abspath( + os.path.join(os.path.dirname(__file__), + "test_data/test_matsim_plansv12.xml") +) + + +@pytest.fixture +def population(): + population = read_matsim(test_plans, version=12) + for hid, pid, person in population.people(): + person.home.area = 'h' + for act in person.activities: + act.location.area = 'h' + return population + + +@pytest.fixture +def choice_model(population, od, data_zones): + return ChoiceModel(population, od, data_zones) + + +def test_zones_are_aligned(population, od, data_zones): + choice_model = ChoiceModel(population, od, data_zones.loc[['b', 'a']]) + zones_destination = choice_model.od.labels.destination_zones + zones_index = list(choice_model.zones.index) + assert zones_destination == zones_index + +def test_utility_calculation(choice_model): + asc = 0.1 + u = """asc + beta_time * od.car.time * leg.mode==car""" \ No newline at end of file From de2be6620a4a9929c3fcfb28e5a1da4cdab29722 Mon Sep 17 00:00:00 2001 From: Theodore-Chatziioannou Date: Thu, 18 May 2023 20:00:19 +0000 Subject: [PATCH 02/14] selection modules --- examples/15_Advanced_Choice.ipynb | 166 ++++++++++++++++-------------- pam/planner/choice.py | 126 ++++++++++++++++++++++- pam/planner/od.py | 20 +++- pam/planner/utils_planner.py | 18 ++++ tests/test_22_planner_od.py | 26 +++-- 5 files changed, 260 insertions(+), 96 deletions(-) create mode 100644 pam/planner/utils_planner.py diff --git a/examples/15_Advanced_Choice.ipynb b/examples/15_Advanced_Choice.ipynb index 0a728260..f2973b76 100644 --- a/examples/15_Advanced_Choice.ipynb +++ b/examples/15_Advanced_Choice.ipynb @@ -117,28 +117,36 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 148, "id": "51c0dc76", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "array([[[[20, 40],\n", - " [40, 20]],\n", - "\n", - " [[ 5, 8],\n", - " [ 8, 5]]],\n", - "\n", - "\n", - " [[[30, 45],\n", - " [45, 30]],\n", - "\n", - " [[ 5, 8],\n", - " [ 8, 5]]]])" + "Origin-destination dataset \n", + "--------------------------------------------------\n", + "Labels(vars=['time', 'distance'], origin_zones=['a', 'b'], destination_zones=['a', 'b'], mode=['car', 'bus'])\n", + "--------------------------------------------------\n", + "time - car:\n", + "[[20 40]\n", + " [ 5 8]]\n", + "--------------------------------------------------\n", + "time - bus:\n", + "[[40 20]\n", + " [ 8 5]]\n", + "--------------------------------------------------\n", + "distance - car:\n", + "[[30 45]\n", + " [ 5 10]]\n", + "--------------------------------------------------\n", + "distance - bus:\n", + "[[45 30]\n", + " [ 9 5]]\n", + "--------------------------------------------------" ] }, - "execution_count": 5, + "execution_count": 148, "metadata": {}, "output_type": "execute_result" } @@ -152,7 +160,7 @@ " ],\n", " [\n", " [[30, 45], [45, 30]],\n", - " [[5, 8], [8, 5]]\n", + " [[5, 9], [10, 5]]\n", " ]\n", " ]\n", ")\n", @@ -170,12 +178,12 @@ " labels=labels\n", ")\n", "\n", - "od.data" + "od" ] }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 149, "id": "760e9d2d", "metadata": {}, "outputs": [], @@ -185,81 +193,87 @@ }, { "cell_type": "code", - "execution_count": 8, - "id": "da1be738", + "execution_count": 277, + "id": "ec2bff68", "metadata": {}, "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " pid hid seq home_loc act_loc \n", - "0 chris chris 1 a a \\\n", - "1 fatema fatema 1 a a \n", - "2 fred fred 3 a a \n", - "3 gerry gerry 3 a a \n", - "4 nick nick 1 a a \n", - "\n", - " act \n", - "0 Activity(act:work, location:POINT (10100 0), t... \n", - "1 Activity(act:work, location:POINT (10100 0), t... \n", - "2 Activity(act:work, location:POINT (10100 0), t... \n", - "3 Activity(act:work, location:POINT (10100 0), t... \n", - "4 Activity(act:work, location:POINT (0 10000), t... \n" - ] - }, { "data": { "text/plain": [ - "array([[1. , 1. , 1. , 1. ],\n", - " [6. , 3.5 , 4.33333333, 3.22222222],\n", - " [6. , 3.5 , 4.33333333, 3.22222222],\n", - " [6. , 3.5 , 4.33333333, 3.22222222],\n", - " [1. , 1. , 1. , 1. ]])" + "ChoiceSet(idxs=[{'pid': 'chris', 'hid': 'chris', 'seq': 1, 'act': }, {'pid': 'fatema', 'hid': 'fatema', 'seq': 1, 'act': }, {'pid': 'fred', 'hid': 'fred', 'seq': 3, 'act': }, {'pid': 'gerry', 'hid': 'gerry', 'seq': 3, 'act': }, {'pid': 'nick', 'hid': 'nick', 'seq': 1, 'act': }], u_choices=array([[0. , 1. , 0. , 1. ],\n", + " [5. , 3.5, 2.5, 6. ],\n", + " [5. , 3.5, 2.5, 6. ],\n", + " [5. , 3.5, 2.5, 6. ],\n", + " [0. , 1. , 0. , 1. ]]), choice_labels=[('a', 'car'), ('a', 'bus'), ('b', 'car'), ('b', 'bus')])" ] }, - "execution_count": 8, + "execution_count": 277, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "# get the choice set, home-based work\n", - "scope_act = 'work'\n", - "var = 'time'\n", - "asc = 1\n", + "scope = \"act.act=='work'\"\n", + "asc = [0, 1]\n", + "# asc = 10\n", "beta_time = [1, 2] # one beta for each mode\n", - "\n", - "u = \"\"\" \\\n", - " asc + (1 * (person.attributes['subpopulation']=='poor')) * \\\n", - " (zones['jobs'].values / (beta_time * od[:, 'time', person.home.area]))\n", + "u = f\"\"\" \\\n", + " {asc} + (1 * (person.attributes['subpopulation']=='poor')) * \\\n", + " (zones['jobs'].values / ({beta_time} * od['time', person.home.area]))\n", "\"\"\"\n", - "index_set = []\n", - "choice_set = []\n", - "od = planner.od\n", - "zones = planner.zones\n", - "for hid, hh in planner.population:\n", - " for pid, person in hh:\n", - " for i, act in enumerate(person.activities):\n", - " if act.act == scope_act: #TODO: scope of function + mandatory locations\n", - " c = {\n", - " 'pid': pid,\n", - " 'hid': hid,\n", - " 'seq': i,\n", - " 'home_loc': person.home.area,\n", - " 'act_loc': act.location.area,\n", - " 'act': act\n", - " }\n", - " p = eval(u)\n", - " p = p.flatten()\n", - " choice_set.append(p)\n", - " index_set.append(c)\n", - " \n", - "index_set = pd.DataFrame(index_set)\n", - "choice_set = np.array(choice_set)\n", - "print(index_set)\n", + "# u = f\"\"\"od['distance', 'b']\"\"\"\n", + "\n", + "choice_set = planner.get_choice_set(u, scope)\n", "choice_set" ] + }, + { + "cell_type": "markdown", + "id": "273b98ca", + "metadata": {}, + "source": [ + "Select:" + ] + }, + { + "cell_type": "code", + "execution_count": 279, + "id": "4d145bf9", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[('a', 'bus'), ('a', 'bus'), ('b', 'bus'), ('a', 'car'), ('b', 'bus')]" + ] + }, + "execution_count": 279, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "selections = planner.get_selections(u, scope)\n", + "selections.selections" + ] + }, + { + "cell_type": "markdown", + "id": "146421fd", + "metadata": {}, + "source": [ + "Apply new locations and mode:" + ] + }, + { + "cell_type": "code", + "execution_count": 274, + "id": "04a78c84", + "metadata": {}, + "outputs": [], + "source": [ + "planner.apply(u, scope)" + ] } ], "metadata": { diff --git a/pam/planner/choice.py b/pam/planner/choice.py index b838e753..5106bae1 100644 --- a/pam/planner/choice.py +++ b/pam/planner/choice.py @@ -1,13 +1,55 @@ """ Choice models for activity synthesis """ -from typing import Optional +from dataclasses import dataclass +from functools import lru_cache, cached_property +import itertools +from typing import Optional, List, NamedTuple, Callable import pandas as pd +import numpy as np from pam.planner.od import OD +from pam.planner.utils_planner import calculate_mnl_probabilities, sample_weighted from pam.core import Population from pam.operations.cropping import link_population from copy import deepcopy + +class ChoiceSet(NamedTuple): + """ MNL Choice set """ + idxs: List + u_choices: np.array + choice_labels: List[tuple] + + +@dataclass +class SelectionSet: + choice_set: ChoiceSet + func_probabilities: Callable + func_selection: Callable + selections = None + + @cached_property + def probabilities(self) -> np.array: + """ + Probabilities for each alternative. + """ + return np.apply_along_axis( + func1d=self.func_probabilities, + axis=1, + arr=self.choice_set.u_choices + ) + + def sample(self): + sampled = np.apply_along_axis( + func1d=self.func_selection, + axis=1, + arr=self.probabilities + ) + sampled_labels = [self.choice_set.choice_labels[x] for x in sampled] + self.selections = sampled_labels + return sampled_labels + + class ChoiceModel: def __init__( self, @@ -30,8 +72,76 @@ def __init__( def configure(): raise NotImplementedError - def apply(): - raise NotImplementedError + def get_choice_set( + self, + u: str, + scope: str, + **kwargs + ) -> ChoiceSet: + """ + Construct an agent's choice set for each activity/leg within scope. + + :param u: The utility function specification, defined as a string. + The string may point to household, person, act, leg, + od, or zone data. + It can also include values and/or mathematical operations. + Parameters may be passed as single values, or as lists + (with each element in the list corresponding to one of the modes in the OD object) + For example: u='-[0,1] - (2 * od['time']) - (od['time'] * person.attributes['age']>60) + :param scope: The scope of the function (for example, work activities). + """ + od = self.od + zones = self.zones + + idxs = [] + u_choices = [] + choice_labels = list(itertools.product( + od.labels.destination_zones, + od.labels.mode + )) + + # iterate across activities + for hid, hh in self.population: + for pid, person in hh: + for i, act in enumerate(person.activities): + if eval(scope): + idx_act = { + 'pid': pid, + 'hid': hid, + 'seq': i, + 'act': act + } + # calculate utilities for each alternative + u_act = eval(u) + # flatten location-mode combinations + u_act = u_act.flatten() + + u_choices.append(u_act) + idxs.append(idx_act) + + u_choices = np.array(u_choices) + # check dimensions + assert u_choices.shape[1] == len(choice_labels) + assert u_choices.shape[0] == len(idxs) + + return ChoiceSet(idxs=idxs, u_choices=u_choices, choice_labels=choice_labels) + + def get_selections(self, u, scope) -> SelectionSet: + selections = SelectionSet( + choice_set=self.get_choice_set(u, scope), + func_probabilities=calculate_mnl_probabilities, + func_selection=sample_weighted + ) + selections.sample() + return selections + + def apply(self, u, scope, apply_location=True, apply_mode=True): + selections = self.get_selections(u, scope) + for (pid, hid, seq, act), s in zip(selections.choice_set.idxs, selections.selections): + if apply_location: + act.location.area = s[0] + if apply_mode: + act.previous.mode = s[1] class ChoiceMNL(ChoiceModel): @@ -41,5 +151,11 @@ class ChoiceMNL(ChoiceModel): def configure(): pass - def apply(): - pass + def apply(self, u, scope, apply_location=True, apply_mode=True): + selections = self.get_selections(u, scope) + for idx, s in zip(selections.choice_set.idxs, selections.selections): + act = idx['act'] + if apply_location: + act.location.area = s[0] + if apply_mode: + act.previous.mode = s[1] diff --git a/pam/planner/od.py b/pam/planner/od.py index 10d58b1b..cc5aff14 100644 --- a/pam/planner/od.py +++ b/pam/planner/od.py @@ -6,11 +6,11 @@ class Labels(NamedTuple): - """ Data labels for the """ - mode: List + """ Data labels for the origin-destination dataset """ vars: List origin_zones: List destination_zones: List + mode: List class OD: @@ -43,8 +43,9 @@ def data_checks(self): "The number of matrix dimensions should be 4 (mode, variable, origin, destination)" for i, (key, labels) in enumerate(zip(self.labels._fields, self.labels)): assert len(labels) == self.data.shape[i], \ - f"The number of {key} labels should match the first dimension of the OD dataset" - + f"The number of {key} labels should match the number of elements" \ + f"in dimension {i} of the OD dataset" + @staticmethod def parse_labels(labels: Union[Labels, List, dict]) -> Labels: """ @@ -58,7 +59,7 @@ def parse_labels(labels: Union[Labels, List, dict]) -> Labels: else: raise ValueError('Please provide a valid label type') return labels - + def __getitem__(self, args): _args = args if isinstance(args, tuple) else tuple([args]) _args_encoded = tuple() @@ -72,3 +73,12 @@ def __getitem__(self, args): return self.data.__getitem__(_args_encoded) + def __repr__(self) -> str: + divider = '-'*50 + '\n' + r = f'Origin-destination dataset \n{divider}' + r += f'{self.labels.__str__()}\n{divider}' + for var in self.labels.vars: + for trmode in self.labels.mode: + r += f'{var} - {trmode}:\n' + r += f'{self[var, :, :, trmode].__str__()}\n{divider}' + return r diff --git a/pam/planner/utils_planner.py b/pam/planner/utils_planner.py new file mode 100644 index 00000000..1e5c0cee --- /dev/null +++ b/pam/planner/utils_planner.py @@ -0,0 +1,18 @@ +import numpy as np +import random +from typing import Union, List + + +def calculate_mnl_probabilities(x: Union[np.array, List]): + """ + Calculates MNL probabilities from a set of alternatives. + """ + return np.exp(x)/np.exp(x).sum() + + +def sample_weighted(weights: np.array) -> int: + """ + Weighted sampling. + Returns the index of the selection. + """ + return random.choices(range(len(weights)), weights=weights, k=1)[0] diff --git a/tests/test_22_planner_od.py b/tests/test_22_planner_od.py index 03a2cd91..1c9128b1 100644 --- a/tests/test_22_planner_od.py +++ b/tests/test_22_planner_od.py @@ -8,16 +8,8 @@ @pytest.fixture def data_od(): matrices = np.array( - [ - [ - [[20, 40], [40, 20]], - [[5, 8], [8, 5]] - ], - [ - [[30, 45], [45, 30]], - [[5, 8], [8, 5]] - ] - ] + [[[[20, 30], [40, 45]], [[40, 45], [20, 30]]], + [[[5, 5], [8, 9]], [[8, 9], [5, 5]]]] ) return matrices @@ -56,3 +48,17 @@ def test_inconsistent_labels_raise_error(data_od, labels): data=data_od, labels=_labels ) + + +def test_od_slicing_is_correctly_encoded(od): + np.testing.assert_equal(od[0], od['time']) + np.testing.assert_equal(od[1], od['distance']) + np.testing.assert_equal(od[1, 0], od['distance', 'a']) + np.testing.assert_equal(od[:], od.data) + np.testing.assert_equal(od[:, :, :, :], od.data) + np.testing.assert_equal(od['time', 'a', 'b', :], np.array([40, 45])) + with pytest.raises(IndexError): + od['_'] + +def test_class_represantation_is_string(od): + assert type(od.__repr__()) == str \ No newline at end of file From 48008a99e66cfcb60ac569db08e009ed9bb0f4ca Mon Sep 17 00:00:00 2001 From: Theodore-Chatziioannou Date: Fri, 19 May 2023 15:23:28 +0000 Subject: [PATCH 03/14] more tests --- examples/15_Advanced_Choice.ipynb | 71 ++++++++++++++++------- pam/planner/choice.py | 94 ++++++++++++++++++++----------- tests/test_22_planner_od.py | 16 +++++- tests/test_24_planner_choice.py | 70 ++++++++++++++++++++--- 4 files changed, 188 insertions(+), 63 deletions(-) diff --git a/examples/15_Advanced_Choice.ipynb b/examples/15_Advanced_Choice.ipynb index f2973b76..0dfdc8e8 100644 --- a/examples/15_Advanced_Choice.ipynb +++ b/examples/15_Advanced_Choice.ipynb @@ -13,7 +13,9 @@ "from pam.operations.cropping import link_population\n", "from pam.planner.od import OD, Labels\n", "import numpy as np\n", - "import os" + "import os\n", + "import logging\n", + "logging.basicConfig(level=logging.DEBUG)" ] }, { @@ -117,7 +119,7 @@ }, { "cell_type": "code", - "execution_count": 148, + "execution_count": 5, "id": "51c0dc76", "metadata": {}, "outputs": [ @@ -146,7 +148,7 @@ "--------------------------------------------------" ] }, - "execution_count": 148, + "execution_count": 5, "metadata": {}, "output_type": "execute_result" } @@ -183,7 +185,7 @@ }, { "cell_type": "code", - "execution_count": 149, + "execution_count": 6, "id": "760e9d2d", "metadata": {}, "outputs": [], @@ -193,21 +195,21 @@ }, { "cell_type": "code", - "execution_count": 277, + "execution_count": 7, "id": "ec2bff68", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "ChoiceSet(idxs=[{'pid': 'chris', 'hid': 'chris', 'seq': 1, 'act': }, {'pid': 'fatema', 'hid': 'fatema', 'seq': 1, 'act': }, {'pid': 'fred', 'hid': 'fred', 'seq': 3, 'act': }, {'pid': 'gerry', 'hid': 'gerry', 'seq': 3, 'act': }, {'pid': 'nick', 'hid': 'nick', 'seq': 1, 'act': }], u_choices=array([[0. , 1. , 0. , 1. ],\n", + "ChoiceSet(idxs=[{'pid': 'chris', 'hid': 'chris', 'seq': 1, 'act': }, {'pid': 'fatema', 'hid': 'fatema', 'seq': 1, 'act': }, {'pid': 'fred', 'hid': 'fred', 'seq': 3, 'act': }, {'pid': 'gerry', 'hid': 'gerry', 'seq': 3, 'act': }, {'pid': 'nick', 'hid': 'nick', 'seq': 1, 'act': }], u_choices=array([[0. , 1. , 0. , 1. ],\n", " [5. , 3.5, 2.5, 6. ],\n", " [5. , 3.5, 2.5, 6. ],\n", " [5. , 3.5, 2.5, 6. ],\n", " [0. , 1. , 0. , 1. ]]), choice_labels=[('a', 'car'), ('a', 'bus'), ('b', 'car'), ('b', 'bus')])" ] }, - "execution_count": 277, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } @@ -223,7 +225,8 @@ "\"\"\"\n", "# u = f\"\"\"od['distance', 'b']\"\"\"\n", "\n", - "choice_set = planner.get_choice_set(u, scope)\n", + "planner.configure(u=u, scope=scope)\n", + "choice_set = planner.get_choice_set()\n", "choice_set" ] }, @@ -237,42 +240,68 @@ }, { "cell_type": "code", - "execution_count": 279, - "id": "4d145bf9", + "execution_count": 8, + "id": "f537733e", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "[('a', 'bus'), ('a', 'bus'), ('b', 'bus'), ('a', 'car'), ('b', 'bus')]" + "ChoiceSet(idxs=[{'pid': 'chris', 'hid': 'chris', 'seq': 1, 'act': }, {'pid': 'fatema', 'hid': 'fatema', 'seq': 1, 'act': }, {'pid': 'fred', 'hid': 'fred', 'seq': 3, 'act': }, {'pid': 'gerry', 'hid': 'gerry', 'seq': 3, 'act': }, {'pid': 'nick', 'hid': 'nick', 'seq': 1, 'act': }], u_choices=array([[0. , 1. , 0. , 1. ],\n", + " [5. , 3.5, 2.5, 6. ],\n", + " [5. , 3.5, 2.5, 6. ],\n", + " [5. , 3.5, 2.5, 6. ],\n", + " [0. , 1. , 0. , 1. ]]), choice_labels=[('a', 'car'), ('a', 'bus'), ('b', 'car'), ('b', 'bus')])" ] }, - "execution_count": 279, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "selections = planner.get_selections(u, scope)\n", - "selections.selections" + "planner.get_choice_set()" ] }, { - "cell_type": "markdown", - "id": "146421fd", + "cell_type": "code", + "execution_count": 9, + "id": "4d145bf9", "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[('a', 'bus'), ('b', 'bus'), ('b', 'bus'), ('a', 'car'), ('b', 'bus')]" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "Apply new locations and mode:" + "# planner.func_probabilities = calculate_mnl_probabilities\n", + "selections = planner.get_selections()\n", + "selections.selections" ] }, { "cell_type": "code", - "execution_count": 274, - "id": "04a78c84", + "execution_count": 10, + "id": "f8dd82e8", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO:pam.planner.choice:Applying choice model...\n" + ] + } + ], "source": [ - "planner.apply(u, scope)" + "planner.apply()" ] } ], diff --git a/pam/planner/choice.py b/pam/planner/choice.py index 5106bae1..858f3d94 100644 --- a/pam/planner/choice.py +++ b/pam/planner/choice.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from functools import lru_cache, cached_property import itertools +import logging from typing import Optional, List, NamedTuple, Callable import pandas as pd import numpy as np @@ -23,10 +24,11 @@ class ChoiceSet(NamedTuple): @dataclass class SelectionSet: + """ Calculate probabilities and select alternative """ choice_set: ChoiceSet func_probabilities: Callable func_selection: Callable - selections = None + _selections = None @cached_property def probabilities(self) -> np.array: @@ -46,11 +48,18 @@ def sample(self): arr=self.probabilities ) sampled_labels = [self.choice_set.choice_labels[x] for x in sampled] - self.selections = sampled_labels + self._selections = sampled_labels return sampled_labels + @property + def selections(self): + if self._selections is None: + self.sample() + return self._selections + class ChoiceModel: + def __init__( self, population: Population, @@ -64,22 +73,26 @@ def __init__( :param od: An object holding origin-destination. :param zones: Zone-level data. """ + self.logger = logging.getLogger(__name__) self.population = population link_population(self.population) self.od = od self.zones = zones.loc[od.labels.destination_zones].copy() - def configure(): - raise NotImplementedError + self.u = None + self.scope = None + self.func_probabilities = None + self.func_selection = None - def get_choice_set( - self, - u: str, - scope: str, - **kwargs - ) -> ChoiceSet: + def configure( + self, + u: str, + scope: str, + func_probabilities: Optional[Callable] = None, + func_selection: Optional[Callable] = None + ): """ - Construct an agent's choice set for each activity/leg within scope. + Specify the model. :param u: The utility function specification, defined as a string. The string may point to household, person, act, leg, @@ -90,8 +103,36 @@ def get_choice_set( For example: u='-[0,1] - (2 * od['time']) - (od['time'] * person.attributes['age']>60) :param scope: The scope of the function (for example, work activities). """ + self.u = u + self.scope = scope + if func_probabilities is not None: + self.func_probabilities = func_probabilities + if func_selection is not None: + self.func_selection = func_selection + + def apply(self, apply_location=True, apply_mode=True): + """ + Apply the choice model to the PAM population, + updating the activity locations and mode choices in scope. + """ + self.logger.info('Applying choice model...') + + selections = self.get_selections() + for idx, s in zip(selections.choice_set.idxs, selections.selections): + act = idx['act'] + if apply_location: + act.location.area = s[0] + if apply_mode and (act.previous is not None): + act.previous.mode = s[1] + + def get_choice_set(self) -> ChoiceSet: + """ + Construct an agent's choice set for each activity/leg within scope. + """ od = self.od zones = self.zones + u = self.u + scope = self.scope idxs = [] u_choices = [] @@ -126,36 +167,21 @@ def get_choice_set( return ChoiceSet(idxs=idxs, u_choices=u_choices, choice_labels=choice_labels) - def get_selections(self, u, scope) -> SelectionSet: + def get_selections(self) -> SelectionSet: selections = SelectionSet( - choice_set=self.get_choice_set(u, scope), - func_probabilities=calculate_mnl_probabilities, - func_selection=sample_weighted + choice_set=self.get_choice_set(), + func_probabilities=self.func_probabilities, + func_selection=self.func_selection ) - selections.sample() return selections - def apply(self, u, scope, apply_location=True, apply_mode=True): - selections = self.get_selections(u, scope) - for (pid, hid, seq, act), s in zip(selections.choice_set.idxs, selections.selections): - if apply_location: - act.location.area = s[0] - if apply_mode: - act.previous.mode = s[1] - class ChoiceMNL(ChoiceModel): """ Implements a Multinomial Logit Choice model """ - def configure(): - pass - def apply(self, u, scope, apply_location=True, apply_mode=True): - selections = self.get_selections(u, scope) - for idx, s in zip(selections.choice_set.idxs, selections.selections): - act = idx['act'] - if apply_location: - act.location.area = s[0] - if apply_mode: - act.previous.mode = s[1] + def __init__(self, population: Population, od: OD, zones: pd.DataFrame) -> None: + super().__init__(population, od, zones) + self.func_probabilities = calculate_mnl_probabilities + self.func_selection = sample_weighted diff --git a/tests/test_22_planner_od.py b/tests/test_22_planner_od.py index 1c9128b1..81502879 100644 --- a/tests/test_22_planner_od.py +++ b/tests/test_22_planner_od.py @@ -60,5 +60,19 @@ def test_od_slicing_is_correctly_encoded(od): with pytest.raises(IndexError): od['_'] + def test_class_represantation_is_string(od): - assert type(od.__repr__()) == str \ No newline at end of file + assert type(od.__repr__()) == str + + +def test_matrix_dimensions_stay_the_same(od): + """ + Regression test: Label dimensions need to stay the same. + To apply the model correctly, + we need the first dimension to select the variable, + the second to select the origin, + the third to select the destination, + and the last to select the mode. + """ + assert od.labels._fields == tuple( + ['vars', 'origin_zones', 'destination_zones', 'mode']) diff --git a/tests/test_24_planner_choice.py b/tests/test_24_planner_choice.py index 6f88d494..7caa61f2 100644 --- a/tests/test_24_planner_choice.py +++ b/tests/test_24_planner_choice.py @@ -1,7 +1,9 @@ import pytest +import numpy as np from tests.test_22_planner_od import data_od, labels, od from tests.test_23_planner_zones import data_zones -from pam.planner.choice import ChoiceModel, ChoiceMNL +from pam.planner.choice import ChoiceModel, ChoiceMNL, ChoiceSet +from pam.planner.utils_planner import sample_weighted import os from pam.read import read_matsim from pam.planner.od import OD @@ -16,16 +18,19 @@ def population(): population = read_matsim(test_plans, version=12) for hid, pid, person in population.people(): - person.home.area = 'h' for act in person.activities: - act.location.area = 'h' + act.location.area = 'a' return population @pytest.fixture -def choice_model(population, od, data_zones): +def choice_model(population, od, data_zones) -> ChoiceModel: return ChoiceModel(population, od, data_zones) +@pytest.fixture +def choice_model_mnl(population, od, data_zones) -> ChoiceModel: + return ChoiceMNL(population, od, data_zones) + def test_zones_are_aligned(population, od, data_zones): choice_model = ChoiceModel(population, od, data_zones.loc[['b', 'a']]) @@ -33,6 +38,57 @@ def test_zones_are_aligned(population, od, data_zones): zones_index = list(choice_model.zones.index) assert zones_destination == zones_index -def test_utility_calculation(choice_model): - asc = 0.1 - u = """asc + beta_time * od.car.time * leg.mode==car""" \ No newline at end of file +@pytest.mark.parametrize('var', ['time', 'distance']) +@pytest.mark.parametrize('zone', ['a', 'b']) +def test_choice_set_reads_od_correctly(choice_model, var, zone): + choice_model.configure( + u = f"""1 / od['{var}', '{zone}']""", + scope = """act.act=='work'""" + ) + choice_set = choice_model.get_choice_set() + assert choice_set.choice_labels == [ + ('a', 'car'), ('a', 'bus'), ('b', 'car'), ('b', 'bus') + ] + for choice in choice_set.u_choices: + np.testing.assert_array_equal( + choice, + 1 / choice_model.od[var, zone].flatten() + ) + +def test_list_parameters_correspond_to_modes(choice_model): + """ + When utility parameters are passed as a list, + they should be applied to each respective mode. + """ + asc = [0, 10] # 0 should be applied to car, 10 to bus + choice_model.configure( + u = f"""{asc} + od['time', 'b']""", + scope = """True""" + ) + choice_set = choice_model.get_choice_set() + u_choices = choice_set.u_choices + choice_labels = choice_set.choice_labels + idx_car = [i for (i, (zone, trmode)) in enumerate(choice_labels) if trmode=='car'] + idx_bus = [i for (i, (zone, trmode)) in enumerate(choice_labels) if trmode=='bus'] + + np.testing.assert_equal( + u_choices[0, idx_car], + choice_model.od['time', 'b', :, 'car'] + ) + np.testing.assert_equal( + u_choices[0, idx_bus], + 10 + choice_model.od['time', 'b', :, 'bus'] + ) + + +def test_get_probabilities(choice_model): + choice_model.configure( + u = f"""1 / od['time', 'b']""", + scope = """True""", + func_probabilities = lambda x: x + ) + choices = choice_model.get_choice_set().u_choices + probs = choice_model.get_selections().probabilities + np.testing.assert_almost_equal( + choices, probs + ) \ No newline at end of file From bebdca2e8fd402474b66dc71ed6e7f60289d43cd Mon Sep 17 00:00:00 2001 From: Theodore-Chatziioannou Date: Fri, 19 May 2023 15:27:43 +0000 Subject: [PATCH 04/14] drop caching --- pam/planner/choice.py | 3 +-- tests/test_24_planner_choice.py | 7 ++++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pam/planner/choice.py b/pam/planner/choice.py index 858f3d94..b6474cf7 100644 --- a/pam/planner/choice.py +++ b/pam/planner/choice.py @@ -2,7 +2,6 @@ Choice models for activity synthesis """ from dataclasses import dataclass -from functools import lru_cache, cached_property import itertools import logging from typing import Optional, List, NamedTuple, Callable @@ -30,7 +29,7 @@ class SelectionSet: func_selection: Callable _selections = None - @cached_property + @property def probabilities(self) -> np.array: """ Probabilities for each alternative. diff --git a/tests/test_24_planner_choice.py b/tests/test_24_planner_choice.py index 7caa61f2..65ccde68 100644 --- a/tests/test_24_planner_choice.py +++ b/tests/test_24_planner_choice.py @@ -81,14 +81,15 @@ def test_list_parameters_correspond_to_modes(choice_model): ) -def test_get_probabilities(choice_model): +def test_get_probabilities_simple_scaling(choice_model): choice_model.configure( u = f"""1 / od['time', 'b']""", scope = """True""", - func_probabilities = lambda x: x + func_probabilities = lambda x: x / sum(x) ) choices = choice_model.get_choice_set().u_choices probs = choice_model.get_selections().probabilities np.testing.assert_almost_equal( - choices, probs + choices / choices.sum(1).reshape(-1, 1), + probs ) \ No newline at end of file From 8e6db225da748c433971600f0c43bd420fbc735d Mon Sep 17 00:00:00 2001 From: Theodore-Chatziioannou Date: Mon, 22 May 2023 14:43:45 +0000 Subject: [PATCH 05/14] more tests --- examples/15_Advanced_Choice.ipynb | 325 ++++++++++++++++++++++-------- pam/planner/choice.py | 49 +++-- pam/planner/utils_planner.py | 2 +- tests/test_24_planner_choice.py | 63 ++++-- tests/test_25_planner_utils.py | 30 +++ 5 files changed, 354 insertions(+), 115 deletions(-) create mode 100644 tests/test_25_planner_utils.py diff --git a/examples/15_Advanced_Choice.ipynb b/examples/15_Advanced_Choice.ipynb index 0dfdc8e8..9f8e5dfa 100644 --- a/examples/15_Advanced_Choice.ipynb +++ b/examples/15_Advanced_Choice.ipynb @@ -1,5 +1,46 @@ { "cells": [ + { + "cell_type": "markdown", + "id": "e225bed3", + "metadata": {}, + "source": [ + "# Introduction" + ] + }, + { + "cell_type": "markdown", + "id": "eaa3f397", + "metadata": {}, + "source": [ + "This notebook applies a simple location and mode choice model to a PAM population." + ] + }, + { + "cell_type": "markdown", + "id": "d8729af8", + "metadata": {}, + "source": [ + "The `pam.planner.choice.ChoiceMNL` model allows the user to apply an MNL specification. \n", + "\n", + "The typical workflow is:\n", + "\n", + "```\n", + "choice_model = ChoiceMNL(population, od, zones) # initialize the model and point to the data objects \n", + "choice_model.configure(u, scope) # configure the model by specifying a utility function and the scope of application.\n", + "choice_model.apply() # apply the model and update the population with the results.\n", + "\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "8a484a5b", + "metadata": {}, + "source": [ + "# Dependencies" + ] + }, { "cell_type": "code", "execution_count": 1, @@ -15,7 +56,8 @@ "import numpy as np\n", "import os\n", "import logging\n", - "logging.basicConfig(level=logging.DEBUG)" + "logging.basicConfig(level=logging.DEBUG)\n", + "from prettytable import PrettyTable" ] }, { @@ -29,6 +71,22 @@ "%autoreload 2" ] }, + { + "cell_type": "markdown", + "id": "d863cc2c", + "metadata": {}, + "source": [ + "# Data" + ] + }, + { + "cell_type": "markdown", + "id": "6d13dd57", + "metadata": {}, + "source": [ + "We read an example population, and set the location of all activities to zone `a`:" + ] + }, { "cell_type": "code", "execution_count": 3, @@ -46,6 +104,52 @@ { "cell_type": "code", "execution_count": 4, + "id": "e1163184", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Work locations and travel modes:\n", + "+--------+-----+----------+------+\n", + "| pid | seq | location | mode |\n", + "+--------+-----+----------+------+\n", + "| chris | 1 | a | car |\n", + "| fatema | 1 | a | bike |\n", + "| fred | 3 | a | walk |\n", + "| gerry | 3 | a | walk |\n", + "| nick | 1 | a | car |\n", + "+--------+-----+----------+------+\n" + ] + } + ], + "source": [ + "def print_activity_locs(population, act_scope='work'):\n", + " summary = PrettyTable(['pid', 'seq', 'location', 'mode'])\n", + " for hid, pid, person in population.people():\n", + " for seq, act in enumerate(person.plan.activities):\n", + " if (act.act==act_scope) or (act_scope=='all'):\n", + " trmode = act.previous.mode if act.previous is not None else 'NA'\n", + " summary.add_row([pid, seq, act.location.area, trmode])\n", + " \n", + " print(summary)\n", + "\n", + "print('Work locations and travel modes:')\n", + "print_activity_locs(population, act_scope='work')" + ] + }, + { + "cell_type": "markdown", + "id": "63e93b1c", + "metadata": {}, + "source": [ + "Our `zones` dataset includes destination attraction data, for example the number of jobs or schools in each likely destination zone:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, "id": "3081485c", "metadata": {}, "outputs": [ @@ -101,7 +205,7 @@ "b 200 1" ] }, - "execution_count": 4, + "execution_count": 5, "metadata": {}, "output_type": "execute_result" } @@ -117,9 +221,17 @@ "data_zones" ] }, + { + "cell_type": "markdown", + "id": "1f6c39ea", + "metadata": {}, + "source": [ + "The `od` objects holds origin-destination data, for example travel time and travel distance between each origin and destination, for each travel mode:" + ] + }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 6, "id": "51c0dc76", "metadata": {}, "outputs": [ @@ -132,40 +244,32 @@ "--------------------------------------------------\n", "time - car:\n", "[[20 40]\n", - " [ 5 8]]\n", + " [40 20]]\n", "--------------------------------------------------\n", "time - bus:\n", - "[[40 20]\n", - " [ 8 5]]\n", + "[[30 45]\n", + " [45 30]]\n", "--------------------------------------------------\n", "distance - car:\n", - "[[30 45]\n", - " [ 5 10]]\n", + "[[5 8]\n", + " [8 5]]\n", "--------------------------------------------------\n", "distance - bus:\n", - "[[45 30]\n", - " [ 9 5]]\n", + "[[5 9]\n", + " [9 5]]\n", "--------------------------------------------------" ] }, - "execution_count": 5, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } ], "source": [ "data_od = np.array(\n", - " [\n", - " [\n", - " [[20, 40], [40, 20]],\n", - " [[5, 8], [8, 5]]\n", - " ],\n", - " [\n", - " [[30, 45], [45, 30]],\n", - " [[5, 9], [10, 5]]\n", - " ]\n", - " ]\n", - ")\n", + " [[[[20, 30], [40, 45]], [[40, 45], [20, 30]]],\n", + " [[[5, 5], [8, 9]], [[8, 9], [5, 5]]]]\n", + " )\n", "\n", "labels = {\n", " 'mode': ['car', 'bus'],\n", @@ -183,9 +287,17 @@ "od" ] }, + { + "cell_type": "markdown", + "id": "c27f75b6", + "metadata": {}, + "source": [ + "# Choice model" + ] + }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 7, "id": "760e9d2d", "metadata": {}, "outputs": [], @@ -194,114 +306,163 @@ ] }, { - "cell_type": "code", - "execution_count": 7, - "id": "ec2bff68", + "cell_type": "markdown", + "id": "0373eaeb", "metadata": {}, + "source": [ + "We configure the model by specifying:\n", + "* the scope of the model. For example, work activities\n", + "* the utility of each alternative\n", + "\n", + "Both options are defined as strings. The stings may comprise mathematical operators, parameters, planner data objects (`od` / `zones`), and/or PAM population objects (`person`/ `act`). \n", + "\n", + "Parameters can be passed as a list, with each element in the list corresponding to one of the modes in the `od` object." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "f368c9df", + "metadata": { + "scrolled": false + }, "outputs": [ { - "data": { - "text/plain": [ - "ChoiceSet(idxs=[{'pid': 'chris', 'hid': 'chris', 'seq': 1, 'act': }, {'pid': 'fatema', 'hid': 'fatema', 'seq': 1, 'act': }, {'pid': 'fred', 'hid': 'fred', 'seq': 3, 'act': }, {'pid': 'gerry', 'hid': 'gerry', 'seq': 3, 'act': }, {'pid': 'nick', 'hid': 'nick', 'seq': 1, 'act': }], u_choices=array([[0. , 1. , 0. , 1. ],\n", - " [5. , 3.5, 2.5, 6. ],\n", - " [5. , 3.5, 2.5, 6. ],\n", - " [5. , 3.5, 2.5, 6. ],\n", - " [0. , 1. , 0. , 1. ]]), choice_labels=[('a', 'car'), ('a', 'bus'), ('b', 'car'), ('b', 'bus')])" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" + "name": "stdout", + "output_type": "stream", + "text": [ + "Utility specification: \n", + " [0,-1]+(np.array([-2,0])*(person.attributes['subpopulation']=='poor'))+([-0.05,-0.07]*od['time',person.home.area])+(0.4*np.log(zones['jobs'].values))\n", + "\n" + ] } ], "source": [ "scope = \"act.act=='work'\"\n", - "asc = [0, 1]\n", - "# asc = 10\n", - "beta_time = [1, 2] # one beta for each mode\n", + "asc = [0, -1] # one value for each mode\n", + "asc_shift_poor = [-2, 0] # one value for each mode\n", + "beta_time = [-0.05, -0.07] # one value for each mode\n", + "beta_zones = 0.4\n", "u = f\"\"\" \\\n", - " {asc} + (1 * (person.attributes['subpopulation']=='poor')) * \\\n", - " (zones['jobs'].values / ({beta_time} * od['time', person.home.area]))\n", + " {asc} + \\\n", + " (np.array({asc_shift_poor}) * (person.attributes['subpopulation']=='poor')) + \\\n", + " ({beta_time} * od['time', person.home.area]) + \\\n", + " ({beta_zones} * np.log(zones['jobs'].values))\n", "\"\"\"\n", - "# u = f\"\"\"od['distance', 'b']\"\"\"\n", "\n", "planner.configure(u=u, scope=scope)\n", - "choice_set = planner.get_choice_set()\n", - "choice_set" + "print('Utility specification: \\n', planner.u)" ] }, { "cell_type": "markdown", - "id": "273b98ca", + "id": "0f74e719", "metadata": {}, "source": [ - "Select:" + "The `.get_choice_set()` provides with with the utilities of each alternative, as perceived by each agent." ] }, { "cell_type": "code", - "execution_count": 8, - "id": "f537733e", + "execution_count": 12, + "id": "48fc5154", "metadata": {}, "outputs": [ { - "data": { - "text/plain": [ - "ChoiceSet(idxs=[{'pid': 'chris', 'hid': 'chris', 'seq': 1, 'act': }, {'pid': 'fatema', 'hid': 'fatema', 'seq': 1, 'act': }, {'pid': 'fred', 'hid': 'fred', 'seq': 3, 'act': }, {'pid': 'gerry', 'hid': 'gerry', 'seq': 3, 'act': }, {'pid': 'nick', 'hid': 'nick', 'seq': 1, 'act': }], u_choices=array([[0. , 1. , 0. , 1. ],\n", - " [5. , 3.5, 2.5, 6. ],\n", - " [5. , 3.5, 2.5, 6. ],\n", - " [5. , 3.5, 2.5, 6. ],\n", - " [0. , 1. , 0. , 1. ]]), choice_labels=[('a', 'car'), ('a', 'bus'), ('b', 'car'), ('b', 'bus')])" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" + "name": "stdout", + "output_type": "stream", + "text": [ + "Activities in scope: \n", + " [{'pid': 'chris', 'hid': 'chris', 'seq': 1, 'act': }, {'pid': 'fatema', 'hid': 'fatema', 'seq': 1, 'act': }, {'pid': 'fred', 'hid': 'fred', 'seq': 3, 'act': }, {'pid': 'gerry', 'hid': 'gerry', 'seq': 3, 'act': }, {'pid': 'nick', 'hid': 'nick', 'seq': 1, 'act': }]\n", + "\n", + "Alternatives: \n", + " [('a', 'car'), ('a', 'bus'), ('b', 'car'), ('b', 'bus')]\n", + "\n", + "Choice set utilities: \n", + " [[ 0.84206807 -0.98067305 -0.15793193 -2.03067305]\n", + " [-1.15793193 -0.98067305 -2.15793193 -2.03067305]\n", + " [-1.15793193 -0.98067305 -2.15793193 -2.03067305]\n", + " [-1.15793193 -0.98067305 -2.15793193 -2.03067305]\n", + " [ 0.84206807 -0.98067305 -0.15793193 -2.03067305]]\n" + ] } ], "source": [ - "planner.get_choice_set()" + "choice_set = planner.get_choice_set()\n", + "print('Activities in scope: \\n', choice_set.idxs)\n", + "print('\\nAlternatives: \\n', choice_set.choice_labels)\n", + "print('\\nChoice set utilities: \\n', choice_set.u_choices)" ] }, { - "cell_type": "code", - "execution_count": 9, - "id": "4d145bf9", + "cell_type": "markdown", + "id": "2befcd05", "metadata": {}, + "source": [ + "The `.apply()` method samples from the alternatives, and updates the location and mode of each activity accordingly:" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "e26b40d1", + "metadata": { + "scrolled": false + }, "outputs": [ { - "data": { - "text/plain": [ - "[('a', 'bus'), ('b', 'bus'), ('b', 'bus'), ('a', 'car'), ('b', 'bus')]" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO:pam.planner.choice:Applying choice model...\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Sampled choices: \n", + " [('a', 'car'), ('b', 'bus'), ('a', 'car'), ('b', 'car'), ('b', 'car')]\n" + ] } ], "source": [ - "# planner.func_probabilities = calculate_mnl_probabilities\n", - "selections = planner.get_selections()\n", - "selections.selections" + "planner.apply()\n", + "print('Sampled choices: \\n', planner._selections.selections)" + ] + }, + { + "cell_type": "markdown", + "id": "01fb7d67", + "metadata": {}, + "source": [ + "The population's activity locations and travel modes have been updated accordingly:" ] }, { "cell_type": "code", - "execution_count": 10, - "id": "f8dd82e8", + "execution_count": 11, + "id": "1f62b0cc", "metadata": {}, "outputs": [ { - "name": "stderr", + "name": "stdout", "output_type": "stream", "text": [ - "INFO:pam.planner.choice:Applying choice model...\n" + "+--------+-----+----------+------+\n", + "| pid | seq | location | mode |\n", + "+--------+-----+----------+------+\n", + "| chris | 1 | a | car |\n", + "| fatema | 1 | b | bus |\n", + "| fred | 3 | a | car |\n", + "| gerry | 3 | b | car |\n", + "| nick | 1 | b | car |\n", + "+--------+-----+----------+------+\n" ] } ], "source": [ - "planner.apply()" + "print_activity_locs(planner.population)" ] } ], diff --git a/pam/planner/choice.py b/pam/planner/choice.py index b6474cf7..c21cb962 100644 --- a/pam/planner/choice.py +++ b/pam/planner/choice.py @@ -16,7 +16,7 @@ class ChoiceSet(NamedTuple): """ MNL Choice set """ - idxs: List + idxs: List[dict] u_choices: np.array choice_labels: List[tuple] @@ -26,7 +26,7 @@ class SelectionSet: """ Calculate probabilities and select alternative """ choice_set: ChoiceSet func_probabilities: Callable - func_selection: Callable + func_sampling: Optional[Callable] = None _selections = None @property @@ -41,8 +41,11 @@ def probabilities(self) -> np.array: ) def sample(self): + """ + Sample from a set of alternative options. + """ sampled = np.apply_along_axis( - func1d=self.func_selection, + func1d=self.func_sampling, axis=1, arr=self.probabilities ) @@ -81,14 +84,15 @@ def __init__( self.u = None self.scope = None self.func_probabilities = None - self.func_selection = None + self.func_sampling = None + self._selections = None def configure( self, u: str, scope: str, func_probabilities: Optional[Callable] = None, - func_selection: Optional[Callable] = None + func_sampling: Optional[Callable] = None ): """ Specify the model. @@ -102,27 +106,40 @@ def configure( For example: u='-[0,1] - (2 * od['time']) - (od['time'] * person.attributes['age']>60) :param scope: The scope of the function (for example, work activities). """ - self.u = u + self.u = u.replace(' ', '') self.scope = scope if func_probabilities is not None: self.func_probabilities = func_probabilities - if func_selection is not None: - self.func_selection = func_selection + if func_sampling is not None: + self.func_sampling = func_sampling - def apply(self, apply_location=True, apply_mode=True): + def apply(self, apply_location=True, apply_mode=True, once_per_agent=True): """ - Apply the choice model to the PAM population, + Apply the choice model to the PAM population, updating the activity locations and mode choices in scope. """ self.logger.info('Applying choice model...') - + + # sample choices selections = self.get_selections() - for idx, s in zip(selections.choice_set.idxs, selections.selections): + self._selections = selections + + pid = None + destination = None + trmode = None + + # update location and mode + for idx, selection in zip(selections.choice_set.idxs, selections.selections): + if not (once_per_agent and (pid == idx['pid'])): + destination = selection[0] + trmode = selection[1] + + pid = idx['pid'] act = idx['act'] if apply_location: - act.location.area = s[0] + act.location.area = destination if apply_mode and (act.previous is not None): - act.previous.mode = s[1] + act.previous.mode = trmode def get_choice_set(self) -> ChoiceSet: """ @@ -170,7 +187,7 @@ def get_selections(self) -> SelectionSet: selections = SelectionSet( choice_set=self.get_choice_set(), func_probabilities=self.func_probabilities, - func_selection=self.func_selection + func_sampling=self.func_sampling ) return selections @@ -183,4 +200,4 @@ class ChoiceMNL(ChoiceModel): def __init__(self, population: Population, od: OD, zones: pd.DataFrame) -> None: super().__init__(population, od, zones) self.func_probabilities = calculate_mnl_probabilities - self.func_selection = sample_weighted + self.func_sampling = sample_weighted diff --git a/pam/planner/utils_planner.py b/pam/planner/utils_planner.py index 1e5c0cee..6f6331d7 100644 --- a/pam/planner/utils_planner.py +++ b/pam/planner/utils_planner.py @@ -3,7 +3,7 @@ from typing import Union, List -def calculate_mnl_probabilities(x: Union[np.array, List]): +def calculate_mnl_probabilities(x: Union[np.array, List]) -> np.array: """ Calculates MNL probabilities from a set of alternatives. """ diff --git a/tests/test_24_planner_choice.py b/tests/test_24_planner_choice.py index 65ccde68..ebbf8aeb 100644 --- a/tests/test_24_planner_choice.py +++ b/tests/test_24_planner_choice.py @@ -1,5 +1,6 @@ import pytest import numpy as np +import random from tests.test_22_planner_od import data_od, labels, od from tests.test_23_planner_zones import data_zones from pam.planner.choice import ChoiceModel, ChoiceMNL, ChoiceSet @@ -27,6 +28,7 @@ def population(): def choice_model(population, od, data_zones) -> ChoiceModel: return ChoiceModel(population, od, data_zones) + @pytest.fixture def choice_model_mnl(population, od, data_zones) -> ChoiceModel: return ChoiceMNL(population, od, data_zones) @@ -38,39 +40,43 @@ def test_zones_are_aligned(population, od, data_zones): zones_index = list(choice_model.zones.index) assert zones_destination == zones_index + @pytest.mark.parametrize('var', ['time', 'distance']) @pytest.mark.parametrize('zone', ['a', 'b']) def test_choice_set_reads_od_correctly(choice_model, var, zone): choice_model.configure( - u = f"""1 / od['{var}', '{zone}']""", - scope = """act.act=='work'""" + u=f"""1 / od['{var}', '{zone}']""", + scope="""act.act=='work'""" ) choice_set = choice_model.get_choice_set() assert choice_set.choice_labels == [ - ('a', 'car'), ('a', 'bus'), ('b', 'car'), ('b', 'bus') + ('a', 'car'), ('a', 'bus'), ('b', 'car'), ('b', 'bus') ] for choice in choice_set.u_choices: np.testing.assert_array_equal( - choice, + choice, 1 / choice_model.od[var, zone].flatten() ) + def test_list_parameters_correspond_to_modes(choice_model): """ When utility parameters are passed as a list, they should be applied to each respective mode. """ - asc = [0, 10] # 0 should be applied to car, 10 to bus + asc = [0, 10] # 0 should be applied to car, 10 to bus choice_model.configure( - u = f"""{asc} + od['time', 'b']""", - scope = """True""" + u=f"""{asc} + od['time', 'b']""", + scope="""True""" ) choice_set = choice_model.get_choice_set() u_choices = choice_set.u_choices choice_labels = choice_set.choice_labels - idx_car = [i for (i, (zone, trmode)) in enumerate(choice_labels) if trmode=='car'] - idx_bus = [i for (i, (zone, trmode)) in enumerate(choice_labels) if trmode=='bus'] - + idx_car = [i for (i, (zone, trmode)) in enumerate( + choice_labels) if trmode == 'car'] + idx_bus = [i for (i, (zone, trmode)) in enumerate( + choice_labels) if trmode == 'bus'] + np.testing.assert_equal( u_choices[0, idx_car], choice_model.od['time', 'b', :, 'car'] @@ -81,15 +87,40 @@ def test_list_parameters_correspond_to_modes(choice_model): ) -def test_get_probabilities_simple_scaling(choice_model): +def test_get_probabilities_along_dimension(choice_model): choice_model.configure( - u = f"""1 / od['time', 'b']""", - scope = """True""", - func_probabilities = lambda x: x / sum(x) + u=f"""1 / od['time', 'b']""", + scope="""True""", + func_probabilities=lambda x: x / sum(x) ) choices = choice_model.get_choice_set().u_choices probs = choice_model.get_selections().probabilities + assert (probs.sum(axis=1) == 1).all() np.testing.assert_almost_equal( - choices / choices.sum(1).reshape(-1, 1), + choices / choices.sum(1).reshape(-1, 1), probs - ) \ No newline at end of file + ) + + +def test_apply_once_per_agent_same_locations(choice_model_mnl): + choice_model_mnl.configure( + u=f"""1 / od['time', 'b']""", + scope="""True""", + func_probabilities=lambda x: x / sum(x) + ) + + def assert_single_location(population): + for hid, pid, person in population.people(): + locs = [act.location.area for act in person.plan.activities] + assert len(set(locs)) == 1 + + with pytest.raises(AssertionError): + random.seed(10) + choice_model_mnl.apply(once_per_agent=False) + assert_single_location(choice_model_mnl.population) + + # only apply once per agent: + choice_model_mnl.apply(once_per_agent=True) + # now all locations should be in the same zone + # for each agent + assert_single_location(choice_model_mnl.population) diff --git a/tests/test_25_planner_utils.py b/tests/test_25_planner_utils.py new file mode 100644 index 00000000..78196756 --- /dev/null +++ b/tests/test_25_planner_utils.py @@ -0,0 +1,30 @@ +import pytest +import numpy as np +import random +from pam.planner.utils_planner import calculate_mnl_probabilities, \ + sample_weighted + + +def test_weighted_sampling_zero_weight(): + choices = np.array([0, 0, 1]) + assert sample_weighted(choices) == 2 + + +def test_weighted_sampling_seed_results(): + choices = np.array([10, 3, 9, 0, 11, 2]) + random.seed(10) + assert sample_weighted(choices) == 2 + + +def test_mnl_probabilities_add_up(): + choices = np.array([10, 3, 9, 0, 11, 2]) + probs = calculate_mnl_probabilities(choices) + assert round(probs.sum(), 4) == 1 + + +def test_mnl_equal_weights_equal_probs(): + n = 10 + choices = np.array([10]*n) + probs = calculate_mnl_probabilities(choices) + assert (probs==(1/n)).all() + From 1937138e117d7c912afb1df408798f8991678fcf Mon Sep 17 00:00:00 2001 From: Theodore-Chatziioannou Date: Mon, 22 May 2023 18:21:56 +0000 Subject: [PATCH 06/14] mode choice - chain --- pam/planner/choice.py | 22 +++++++++++++--- pam/planner/utils_planner.py | 46 +++++++++++++++++++++++++++++++++ tests/test_24_planner_choice.py | 12 +++++++++ tests/test_25_planner_utils.py | 45 +++++++++++++++++++++++++++++++- 4 files changed, 121 insertions(+), 4 deletions(-) diff --git a/pam/planner/choice.py b/pam/planner/choice.py index c21cb962..26feafe2 100644 --- a/pam/planner/choice.py +++ b/pam/planner/choice.py @@ -1,6 +1,7 @@ """ Choice models for activity synthesis """ +from copy import deepcopy from dataclasses import dataclass import itertools import logging @@ -8,8 +9,10 @@ import pandas as pd import numpy as np from pam.planner.od import OD -from pam.planner.utils_planner import calculate_mnl_probabilities, sample_weighted +from pam.planner.utils_planner import calculate_mnl_probabilities, sample_weighted, \ + get_trip_chains, apply_mode_to_home_chain from pam.core import Population +from pam.activity import Activity, Leg from pam.operations.cropping import link_population from copy import deepcopy @@ -113,10 +116,20 @@ def configure( if func_sampling is not None: self.func_sampling = func_sampling - def apply(self, apply_location=True, apply_mode=True, once_per_agent=True): + def apply(self, apply_location=True, apply_mode=True, once_per_agent=True, + apply_mode_to='chain'): """ Apply the choice model to the PAM population, updating the activity locations and mode choices in scope. + + :param apply_location: Whether to update activities' location + :param apply_mode: Whether to update travel modes + :param once_per_agent: If True, the same selected option + is applied to all activities within scope of an agent. + :param apply_mode_to: `chain` or `previous`: + Whether to apply the mode to the entire trip chain + that contains the activity, + or the trip preceding the activity. """ self.logger.info('Applying choice model...') @@ -139,7 +152,10 @@ def apply(self, apply_location=True, apply_mode=True, once_per_agent=True): if apply_location: act.location.area = destination if apply_mode and (act.previous is not None): - act.previous.mode = trmode + if apply_mode_to == 'chain': + apply_mode_to_home_chain(act, trmode) + elif apply_mode_to == 'previous_leg': + act.previous.mode = trmode def get_choice_set(self) -> ChoiceSet: """ diff --git a/pam/planner/utils_planner.py b/pam/planner/utils_planner.py index 6f6331d7..ef05f447 100644 --- a/pam/planner/utils_planner.py +++ b/pam/planner/utils_planner.py @@ -1,6 +1,8 @@ +from copy import deepcopy import numpy as np import random from typing import Union, List +from pam.activity import Plan, Activity, Leg def calculate_mnl_probabilities(x: Union[np.array, List]) -> np.array: @@ -16,3 +18,47 @@ def sample_weighted(weights: np.array) -> int: Returns the index of the selection. """ return random.choices(range(len(weights)), weights=weights, k=1)[0] + + +def get_trip_chains( + plan: Plan, + act: str = 'home' +) -> List[List[Union[Activity, Leg]]]: + """ + Get trip chains starting and/OR ending at a long-term activity + """ + chains = [] + chain = [] + for elem in plan.day: + if isinstance(elem, Activity) and elem.act == act: + if len(chain) > 0: + chains.append(chain+[elem]) + chain = [] + chain.append(elem) + + if len(chain) > 1: + chains += [chain] # add any remaining trips until the end of the day + + return chains + +def apply_mode_to_home_chain(act: Activity, trmode: str): + """ + Apply a transport mode across a home-based trip chain, + which comprises a specified activity. + + :param act: The activity that is used part of the trip chain. + :param trmode: The mode to apply to each leg of the chain. + """ + # apply forwards + elem = act.next + while (elem is not None) and (elem.act != 'home'): + if isinstance(elem, Leg): + elem.mode = trmode + elem = elem.next + + # apply backwards + elem = act.previous + while (elem is not None) and (elem.act != 'home'): + if isinstance(elem, Leg): + elem.mode = trmode + elem = elem.previous \ No newline at end of file diff --git a/tests/test_24_planner_choice.py b/tests/test_24_planner_choice.py index ebbf8aeb..397083a2 100644 --- a/tests/test_24_planner_choice.py +++ b/tests/test_24_planner_choice.py @@ -24,6 +24,18 @@ def population(): return population +test_plans_experienced = os.path.abspath( + os.path.join(os.path.dirname(__file__), + "test_data/test_matsim_experienced_plans_v12.xml") +) + + +@pytest.fixture +def population_experienced(): + population = read_matsim(test_plans_experienced, version=12) + return population + + @pytest.fixture def choice_model(population, od, data_zones) -> ChoiceModel: return ChoiceModel(population, od, data_zones) diff --git a/tests/test_25_planner_utils.py b/tests/test_25_planner_utils.py index 78196756..353082b1 100644 --- a/tests/test_25_planner_utils.py +++ b/tests/test_25_planner_utils.py @@ -1,8 +1,25 @@ import pytest import numpy as np import random +import os +from pam.read import read_matsim +from pam.activity import Leg +from pam.operations.cropping import link_population + from pam.planner.utils_planner import calculate_mnl_probabilities, \ - sample_weighted + sample_weighted, get_trip_chains, apply_mode_to_home_chain + +test_plans_experienced = os.path.abspath( + os.path.join(os.path.dirname(__file__), + "test_data/test_matsim_experienced_plans_v12.xml") +) + + +@pytest.fixture +def population_experienced(): + population = read_matsim(test_plans_experienced, version=12) + return population + def test_weighted_sampling_zero_weight(): @@ -28,3 +45,29 @@ def test_mnl_equal_weights_equal_probs(): probs = calculate_mnl_probabilities(choices) assert (probs==(1/n)).all() + +def test_get_home_trip_chains(population_experienced): + person = population_experienced['agent_1']['agent_1'] + person.plan.day[12].act = 'home' + chains = get_trip_chains(person.plan) + assert len(chains) == 2 + assert chains[0][-1] == person.plan.day[12] + assert chains[1][0] == person.plan.day[12] + +def test_apply_mode_to_chain(population_experienced): + link_population(population_experienced) + person = population_experienced['agent_1']['agent_1'] + person.plan.day[12].act = 'home' + chains = get_trip_chains(person.plan) + apply_mode_to_home_chain( + person.plan.day[10], 'gondola' + ) + + # mode is applied to all legs in the chain + legs = [elem for elem in chains[0] if isinstance(elem, Leg)] + assert all([leg.mode=='gondola' for leg in legs]) + + # ..but not to the next trip chain + legs = [elem for elem in chains[1] if isinstance(elem, Leg)] + assert all([leg.mode!='gondola' for leg in legs]) + From 14e8847d45a43f941f2547f92feee62f6991360a Mon Sep 17 00:00:00 2001 From: Theodore-Chatziioannou Date: Tue, 23 May 2023 13:35:56 +0000 Subject: [PATCH 07/14] design improvements --- examples/15_Advanced_Choice.ipynb | 103 ++++++++++++------ pam/planner/choice.py | 171 ++++++++++++++++++------------ pam/planner/utils_planner.py | 21 +++- pam/planner/zones.py | 15 ++- tests/test_23_planner_zones.py | 6 ++ tests/test_24_planner_choice.py | 27 ++++- tests/test_25_planner_utils.py | 12 ++- 7 files changed, 248 insertions(+), 107 deletions(-) diff --git a/examples/15_Advanced_Choice.ipynb b/examples/15_Advanced_Choice.ipynb index 9f8e5dfa..bde47fc5 100644 --- a/examples/15_Advanced_Choice.ipynb +++ b/examples/15_Advanced_Choice.ipynb @@ -21,9 +21,9 @@ "id": "d8729af8", "metadata": {}, "source": [ - "The `pam.planner.choice.ChoiceMNL` model allows the user to apply an MNL specification. \n", + "The `pam.planner.choice.ChoiceMNL` class allows the user to apply an MNL specification for selecting the location of activities and the mode for accessing them, given person characteristics, network conditions and/or zone attraction data.\n", "\n", - "The typical workflow is:\n", + "The typical workflow goes as follows:\n", "\n", "```\n", "choice_model = ChoiceMNL(population, od, zones) # initialize the model and point to the data objects \n", @@ -43,7 +43,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 14, "id": "82e63bf7", "metadata": {}, "outputs": [], @@ -53,11 +53,14 @@ "from pam.planner import choice\n", "from pam.operations.cropping import link_population\n", "from pam.planner.od import OD, Labels\n", + "import random\n", "import numpy as np\n", "import os\n", + "from prettytable import PrettyTable\n", + "\n", "import logging\n", "logging.basicConfig(level=logging.DEBUG)\n", - "from prettytable import PrettyTable" + "random.seed(0)" ] }, { @@ -287,6 +290,35 @@ "od" ] }, + { + "cell_type": "markdown", + "id": "55081a23", + "metadata": {}, + "source": [ + "The dimensions of the `od` objects are (in order): `variables`, `origin zone`, `destination zone`, and `mode`. It can be sliced using the respective labels under `od.labels`, for example:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "243511f7", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "45" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "od['time', 'a', 'b', 'bus']" + ] + }, { "cell_type": "markdown", "id": "c27f75b6", @@ -297,10 +329,19 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 18, "id": "760e9d2d", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO:pam.planner.choice:Updated model configuration\n", + "INFO:pam.planner.choice:ChoiceConfiguration(u=None, scope=None, func_probabilities=, func_sampling=)\n" + ] + } + ], "source": [ "planner = choice.ChoiceMNL(population, od, data_zones)" ] @@ -311,35 +352,34 @@ "metadata": {}, "source": [ "We configure the model by specifying:\n", - "* the scope of the model. For example, work activities\n", - "* the utility of each alternative\n", + "* the scope of the model. For example, work activities.\n", + "* the utility formulation of each alternative.\n", "\n", - "Both options are defined as strings. The stings may comprise mathematical operators, parameters, planner data objects (`od` / `zones`), and/or PAM population objects (`person`/ `act`). \n", + "Both settings are defined as strings. The stings may comprise mathematical operators, coefficients, planner data objects (`od` / `zones`), and/or PAM population objects (`person`/ `act`). \n", "\n", - "Parameters can be passed as a list, with each element in the list corresponding to one of the modes in the `od` object." + "Coefficients can be passed either as a number, or as a list, with each element in the list corresponding to one of the modes in the `od` object." ] }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 19, "id": "f368c9df", "metadata": { "scrolled": false }, "outputs": [ { - "name": "stdout", + "name": "stderr", "output_type": "stream", "text": [ - "Utility specification: \n", - " [0,-1]+(np.array([-2,0])*(person.attributes['subpopulation']=='poor'))+([-0.05,-0.07]*od['time',person.home.area])+(0.4*np.log(zones['jobs'].values))\n", - "\n" + "INFO:pam.planner.choice:Updated model configuration\n", + "INFO:pam.planner.choice:ChoiceConfiguration(u=\"[0,-1]+(np.array([-2,0])*(person.attributes['subpopulation']=='poor'))+([-0.05,-0.07]*od['time',person.home.area])+(0.4*np.log(zones['jobs'].values))\\n\", scope=\"act.act=='work'\", func_probabilities=, func_sampling=)\n" ] } ], "source": [ "scope = \"act.act=='work'\"\n", - "asc = [0, -1] # one value for each mode\n", + "asc = [0, -1] # one value for each mode, 0->car, -1->\n", "asc_shift_poor = [-2, 0] # one value for each mode\n", "beta_time = [-0.05, -0.07] # one value for each mode\n", "beta_zones = 0.4\n", @@ -351,7 +391,7 @@ "\"\"\"\n", "\n", "planner.configure(u=u, scope=scope)\n", - "print('Utility specification: \\n', planner.u)" + "# print('Utility specification: \\n', planner.u)" ] }, { @@ -364,7 +404,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 20, "id": "48fc5154", "metadata": {}, "outputs": [ @@ -373,10 +413,10 @@ "output_type": "stream", "text": [ "Activities in scope: \n", - " [{'pid': 'chris', 'hid': 'chris', 'seq': 1, 'act': }, {'pid': 'fatema', 'hid': 'fatema', 'seq': 1, 'act': }, {'pid': 'fred', 'hid': 'fred', 'seq': 3, 'act': }, {'pid': 'gerry', 'hid': 'gerry', 'seq': 3, 'act': }, {'pid': 'nick', 'hid': 'nick', 'seq': 1, 'act': }]\n", + " [ChoiceIdx(pid='chris', hid='chris', seq=1, act=), ChoiceIdx(pid='fatema', hid='fatema', seq=1, act=), ChoiceIdx(pid='fred', hid='fred', seq=3, act=), ChoiceIdx(pid='gerry', hid='gerry', seq=3, act=), ChoiceIdx(pid='nick', hid='nick', seq=1, act=)]\n", "\n", "Alternatives: \n", - " [('a', 'car'), ('a', 'bus'), ('b', 'car'), ('b', 'bus')]\n", + " [ChoiceLabel(destination='a', mode='car'), ChoiceLabel(destination='a', mode='bus'), ChoiceLabel(destination='b', mode='car'), ChoiceLabel(destination='b', mode='bus')]\n", "\n", "Choice set utilities: \n", " [[ 0.84206807 -0.98067305 -0.15793193 -2.03067305]\n", @@ -404,7 +444,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 22, "id": "e26b40d1", "metadata": { "scrolled": false @@ -414,7 +454,10 @@ "name": "stderr", "output_type": "stream", "text": [ - "INFO:pam.planner.choice:Applying choice model...\n" + "INFO:pam.planner.choice:Applying choice model...\n", + "INFO:pam.planner.choice:Configuration: \n", + "ChoiceConfiguration(u=\"[0,-1]+(np.array([-2,0])*(person.attributes['subpopulation']=='poor'))+([-0.05,-0.07]*od['time',person.home.area])+(0.4*np.log(zones['jobs'].values))\\n\", scope=\"act.act=='work'\", func_probabilities=, func_sampling=)\n", + "INFO:pam.planner.choice:Choice model application complete.\n" ] }, { @@ -422,7 +465,7 @@ "output_type": "stream", "text": [ "Sampled choices: \n", - " [('a', 'car'), ('b', 'bus'), ('a', 'car'), ('b', 'car'), ('b', 'car')]\n" + " [ChoiceLabel(destination='b', mode='car'), ChoiceLabel(destination='b', mode='car'), ChoiceLabel(destination='a', mode='bus'), ChoiceLabel(destination='a', mode='car'), ChoiceLabel(destination='a', mode='car')]\n" ] } ], @@ -441,8 +484,8 @@ }, { "cell_type": "code", - "execution_count": 11, - "id": "1f62b0cc", + "execution_count": 23, + "id": "a3396511", "metadata": {}, "outputs": [ { @@ -452,11 +495,11 @@ "+--------+-----+----------+------+\n", "| pid | seq | location | mode |\n", "+--------+-----+----------+------+\n", - "| chris | 1 | a | car |\n", - "| fatema | 1 | b | bus |\n", - "| fred | 3 | a | car |\n", - "| gerry | 3 | b | car |\n", - "| nick | 1 | b | car |\n", + "| chris | 1 | b | car |\n", + "| fatema | 1 | b | car |\n", + "| fred | 3 | a | bus |\n", + "| gerry | 3 | a | car |\n", + "| nick | 1 | a | car |\n", "+--------+-----+----------+------+\n" ] } diff --git a/pam/planner/choice.py b/pam/planner/choice.py index 26feafe2..c06f10fd 100644 --- a/pam/planner/choice.py +++ b/pam/planner/choice.py @@ -1,7 +1,6 @@ """ Choice models for activity synthesis """ -from copy import deepcopy from dataclasses import dataclass import itertools import logging @@ -10,18 +9,31 @@ import numpy as np from pam.planner.od import OD from pam.planner.utils_planner import calculate_mnl_probabilities, sample_weighted, \ - get_trip_chains, apply_mode_to_home_chain + apply_mode_to_home_chain from pam.core import Population from pam.activity import Activity, Leg from pam.operations.cropping import link_population -from copy import deepcopy + + +class ChoiceLabel(NamedTuple): + """ Destination and mode choice labels of a selected option """ + destination: str + mode: str + + +class ChoiceIdx(NamedTuple): + """ Choice set index """ + pid: str + hid: str + seq: int + act: Activity class ChoiceSet(NamedTuple): """ MNL Choice set """ - idxs: List[dict] + idxs: List[ChoiceIdx] u_choices: np.array - choice_labels: List[tuple] + choice_labels: List[ChoiceLabel] @dataclass @@ -43,7 +55,7 @@ def probabilities(self) -> np.array: arr=self.choice_set.u_choices ) - def sample(self): + def sample(self) -> List: """ Sample from a set of alternative options. """ @@ -57,19 +69,47 @@ def sample(self): return sampled_labels @property - def selections(self): + def selections(self) -> List[ChoiceLabel]: if self._selections is None: self.sample() return self._selections +@dataclass +class ChoiceConfiguration: + """ + :param u: The utility function specification, defined as a string. + The string may point to household, person, act, leg, + od, or zone data. + It can also include values and/or mathematical operations. + Parameters may be passed as single values, or as lists + (with each element in the list corresponding to one of the modes in the OD object) + For example: u='-[0,1] - (2 * od['time']) - (od['time'] * person.attributes['age']>60) + :param scope: The scope of the function (for example, work activities). + :param func_probabilities: The function for calculating the probability of each alternative + :param func_sampling: The function for sampling across alternatives, ie softmax + """ + u: Optional[str] = None + scope: Optional[str] = None + func_probabilities: Optional[Callable] = None + func_sampling: Optional[Callable] = None + + def validate(self, vars:List[str]) -> None: + """ + Return an error if a value has not been set + """ + for var in vars: + if getattr(self, var) is None: + raise ValueError(f'Setting {var} has not been set yet') + + class ChoiceModel: def __init__( self, population: Population, od: OD, - zones: pd.DataFrame + zones: pd.DataFrame # TODO: switch to pam.planner.zones.Zones object ) -> None: """ Choice model interface. @@ -83,40 +123,24 @@ def __init__( link_population(self.population) self.od = od self.zones = zones.loc[od.labels.destination_zones].copy() - - self.u = None - self.scope = None - self.func_probabilities = None - self.func_sampling = None + self.configuration = ChoiceConfiguration() self._selections = None - def configure( - self, - u: str, - scope: str, - func_probabilities: Optional[Callable] = None, - func_sampling: Optional[Callable] = None - ): + def configure(self, **kwargs) -> None: """ - Specify the model. - - :param u: The utility function specification, defined as a string. - The string may point to household, person, act, leg, - od, or zone data. - It can also include values and/or mathematical operations. - Parameters may be passed as single values, or as lists - (with each element in the list corresponding to one of the modes in the OD object) - For example: u='-[0,1] - (2 * od['time']) - (od['time'] * person.attributes['age']>60) - :param scope: The scope of the function (for example, work activities). + Specify the model. + + :Keyword Arguments: Parameters of the ChoiceConfiguration class. """ - self.u = u.replace(' ', '') - self.scope = scope - if func_probabilities is not None: - self.func_probabilities = func_probabilities - if func_sampling is not None: - self.func_sampling = func_sampling - - def apply(self, apply_location=True, apply_mode=True, once_per_agent=True, + for k, v in kwargs.items(): + if type(v) == str: + v = v.replace(' ', '') + setattr(self.configuration, k, v) + self.logger.info('Updated model configuration') + self.logger.info(self.configuration) + + + def apply(self, apply_location=True, apply_mode=True, once_per_agent=True, apply_mode_to='chain'): """ Apply the choice model to the PAM population, @@ -126,45 +150,49 @@ def apply(self, apply_location=True, apply_mode=True, once_per_agent=True, :param apply_mode: Whether to update travel modes :param once_per_agent: If True, the same selected option is applied to all activities within scope of an agent. - :param apply_mode_to: `chain` or `previous`: + :param apply_mode_to: `chain` or `previous_leg`: Whether to apply the mode to the entire trip chain that contains the activity, - or the trip preceding the activity. + or the leg preceding the activity. """ self.logger.info('Applying choice model...') - - # sample choices - selections = self.get_selections() - self._selections = selections + self.logger.info(f'Configuration: \n{self.configuration}') pid = None destination = None trmode = None # update location and mode - for idx, selection in zip(selections.choice_set.idxs, selections.selections): - if not (once_per_agent and (pid == idx['pid'])): - destination = selection[0] - trmode = selection[1] + for idx, selection in zip(self.selections.choice_set.idxs, self.selections.selections): + if not (once_per_agent and (pid == idx.pid)): + destination = selection.destination + trmode = selection.mode + + pid = idx.pid + act = idx.act - pid = idx['pid'] - act = idx['act'] if apply_location: act.location.area = destination + if apply_mode and (act.previous is not None): if apply_mode_to == 'chain': apply_mode_to_home_chain(act, trmode) elif apply_mode_to == 'previous_leg': act.previous.mode = trmode + else: + raise ValueError(f'Invalid option {apply_mode_to}') + + self.logger.info('Choice model application complete.') def get_choice_set(self) -> ChoiceSet: """ Construct an agent's choice set for each activity/leg within scope. """ + self.configuration.validate(['u', 'scope']) od = self.od zones = self.zones - u = self.u - scope = self.scope + u = self.configuration.u + scope = self.configuration.scope idxs = [] u_choices = [] @@ -172,18 +200,19 @@ def get_choice_set(self) -> ChoiceSet: od.labels.destination_zones, od.labels.mode )) + choice_labels = [ChoiceLabel(*x) for x in choice_labels] # iterate across activities for hid, hh in self.population: for pid, person in hh: for i, act in enumerate(person.activities): if eval(scope): - idx_act = { - 'pid': pid, - 'hid': hid, - 'seq': i, - 'act': act - } + idx_act = ChoiceIdx( + pid=pid, + hid=hid, + seq=i, + act=act + ) # calculate utilities for each alternative u_act = eval(u) # flatten location-mode combinations @@ -193,27 +222,33 @@ def get_choice_set(self) -> ChoiceSet: idxs.append(idx_act) u_choices = np.array(u_choices) + # check dimensions assert u_choices.shape[1] == len(choice_labels) assert u_choices.shape[0] == len(idxs) return ChoiceSet(idxs=idxs, u_choices=u_choices, choice_labels=choice_labels) - def get_selections(self) -> SelectionSet: - selections = SelectionSet( - choice_set=self.get_choice_set(), - func_probabilities=self.func_probabilities, - func_sampling=self.func_sampling - ) - return selections + @property + def selections(self) -> SelectionSet: + self.configuration.validate(['func_probabilities', 'func_sampling']) + if self._selections is None: + self._selections = SelectionSet( + choice_set=self.get_choice_set(), + func_probabilities=self.configuration.func_probabilities, + func_sampling=self.configuration.func_sampling + ) + return self._selections class ChoiceMNL(ChoiceModel): """ - Implements a Multinomial Logit Choice model + Applies a Multinomial Logit Choice model. """ def __init__(self, population: Population, od: OD, zones: pd.DataFrame) -> None: super().__init__(population, od, zones) - self.func_probabilities = calculate_mnl_probabilities - self.func_sampling = sample_weighted + self.configure( + func_probabilities = calculate_mnl_probabilities, + func_sampling = sample_weighted + ) diff --git a/pam/planner/utils_planner.py b/pam/planner/utils_planner.py index ef05f447..faaceabb 100644 --- a/pam/planner/utils_planner.py +++ b/pam/planner/utils_planner.py @@ -1,4 +1,3 @@ -from copy import deepcopy import numpy as np import random from typing import Union, List @@ -25,7 +24,7 @@ def get_trip_chains( act: str = 'home' ) -> List[List[Union[Activity, Leg]]]: """ - Get trip chains starting and/OR ending at a long-term activity + Get trip chains starting and/or ending at a long-term activity """ chains = [] chain = [] @@ -44,11 +43,14 @@ def get_trip_chains( def apply_mode_to_home_chain(act: Activity, trmode: str): """ Apply a transport mode across a home-based trip chain, - which comprises a specified activity. + which comprises the specified activity. - :param act: The activity that is used part of the trip chain. + :param act: The activity that is part of the trip chain. :param trmode: The mode to apply to each leg of the chain. """ + if not 'next' in act.__dict__: + raise KeyError('Plan is not linked. Please use `pam.operations.cropping.link_plan` to link activities and legs.') + # apply forwards elem = act.next while (elem is not None) and (elem.act != 'home'): @@ -61,4 +63,13 @@ def apply_mode_to_home_chain(act: Activity, trmode: str): while (elem is not None) and (elem.act != 'home'): if isinstance(elem, Leg): elem.mode = trmode - elem = elem.previous \ No newline at end of file + elem = elem.previous + +def get_validate(obj, name: str): + """ + Get an object's attribute, or raise an error if its value is None. + """ + attr = getattr(obj, name) + if attr is None: + raise ValueError(f'Attribute {name} has not been set yet') + return attr \ No newline at end of file diff --git a/pam/planner/zones.py b/pam/planner/zones.py index 38f1913c..c38ab005 100644 --- a/pam/planner/zones.py +++ b/pam/planner/zones.py @@ -2,13 +2,26 @@ Manages zone-level data required by the planner module. """ +import pandas as pd +import numpy as np class Zones: def __init__( self, - data + data: pd.DataFrame ) -> None: """ :param data: A dataframe with variables as columns and the zone as index """ self.data = data + + def __getattr__(self, __name: str) -> np.array: + return self.data[__name].values + + def __getitem__(self, __name: str) -> np.array: + return self.__getattr__(__name) + + def __repr__(self) -> str: + r = 'Attraction data\n' + r += repr(self.data) + return r diff --git a/tests/test_23_planner_zones.py b/tests/test_23_planner_zones.py index 32e81f04..14dd8b3d 100644 --- a/tests/test_23_planner_zones.py +++ b/tests/test_23_planner_zones.py @@ -15,6 +15,9 @@ def data_zones(): ).set_index('zone') return df +@pytest.fixture +def zones(data_zones): + return Zones(data=data_zones) def test_get_zone_data(data_zones): np.testing.assert_equal(data_zones.loc['b'].values, np.array([200, 1])) @@ -27,3 +30,6 @@ def test_get_variable_data(data_zones): def test_get_values(data_zones): assert data_zones.loc['b', 'jobs'] == 200 assert data_zones.loc['b']['jobs'] == 200 + +def test_get_variable(zones): + np.testing.assert_equal(zones.jobs, np.array([100, 200])) \ No newline at end of file diff --git a/tests/test_24_planner_choice.py b/tests/test_24_planner_choice.py index 397083a2..599570a3 100644 --- a/tests/test_24_planner_choice.py +++ b/tests/test_24_planner_choice.py @@ -3,7 +3,8 @@ import random from tests.test_22_planner_od import data_od, labels, od from tests.test_23_planner_zones import data_zones -from pam.planner.choice import ChoiceModel, ChoiceMNL, ChoiceSet +from pam.planner.choice import ChoiceModel, ChoiceMNL, ChoiceSet, \ + ChoiceConfiguration from pam.planner.utils_planner import sample_weighted import os from pam.read import read_matsim @@ -106,7 +107,7 @@ def test_get_probabilities_along_dimension(choice_model): func_probabilities=lambda x: x / sum(x) ) choices = choice_model.get_choice_set().u_choices - probs = choice_model.get_selections().probabilities + probs = choice_model.selections.probabilities assert (probs.sum(axis=1) == 1).all() np.testing.assert_almost_equal( choices / choices.sum(1).reshape(-1, 1), @@ -136,3 +137,25 @@ def assert_single_location(population): # now all locations should be in the same zone # for each agent assert_single_location(choice_model_mnl.population) + + +def test_nonset_config_attribute_valitation_raise_error(): + config = ChoiceConfiguration() + + with pytest.raises(ValueError): + config.validate(['u', 'scope']) + + config.u = '1' + config.scope = 'True' + config.validate(['u', 'scope']) + + +def test_model_checks_config_requirements(mocker, choice_model_mnl): + mocker.patch.object(ChoiceConfiguration, 'validate') + choice_model_mnl.configure( + u=f"""1 / od['time', 'a']""", + scope="""act.act=='work'""" + ) + + choice_model_mnl.get_choice_set() + ChoiceConfiguration.validate.assert_called_once_with(['u', 'scope']) diff --git a/tests/test_25_planner_utils.py b/tests/test_25_planner_utils.py index 353082b1..5843b280 100644 --- a/tests/test_25_planner_utils.py +++ b/tests/test_25_planner_utils.py @@ -7,7 +7,8 @@ from pam.operations.cropping import link_population from pam.planner.utils_planner import calculate_mnl_probabilities, \ - sample_weighted, get_trip_chains, apply_mode_to_home_chain + sample_weighted, get_trip_chains, apply_mode_to_home_chain, \ + get_validate test_plans_experienced = os.path.abspath( os.path.join(os.path.dirname(__file__), @@ -71,3 +72,12 @@ def test_apply_mode_to_chain(population_experienced): legs = [elem for elem in chains[1] if isinstance(elem, Leg)] assert all([leg.mode!='gondola' for leg in legs]) + +def test_nonset_attribute_raises_error(): + class A: + a = 'b' + b = None + + a = A() + with pytest.raises(ValueError): + get_validate(a, 'b') \ No newline at end of file From 53335ba53000f061c42f16c8c38d2802170ed098 Mon Sep 17 00:00:00 2001 From: Theodore-Chatziioannou Date: Tue, 23 May 2023 13:37:28 +0000 Subject: [PATCH 08/14] design improvements --- examples/15_Advanced_Choice.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/15_Advanced_Choice.ipynb b/examples/15_Advanced_Choice.ipynb index bde47fc5..babcf785 100644 --- a/examples/15_Advanced_Choice.ipynb +++ b/examples/15_Advanced_Choice.ipynb @@ -479,13 +479,13 @@ "id": "01fb7d67", "metadata": {}, "source": [ - "The population's activity locations and travel modes have been updated accordingly:" + "The population's activity locations and travel modes have now been updated accordingly:" ] }, { "cell_type": "code", "execution_count": 23, - "id": "a3396511", + "id": "4bfa1fee", "metadata": {}, "outputs": [ { From 0126efd10938800e5fa705e2ef28f2c432c15753 Mon Sep 17 00:00:00 2001 From: Theodore-Chatziioannou Date: Tue, 23 May 2023 13:49:53 +0000 Subject: [PATCH 09/14] bug fix --- tests/test_24_planner_choice.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_24_planner_choice.py b/tests/test_24_planner_choice.py index 599570a3..f4044cd5 100644 --- a/tests/test_24_planner_choice.py +++ b/tests/test_24_planner_choice.py @@ -104,7 +104,8 @@ def test_get_probabilities_along_dimension(choice_model): choice_model.configure( u=f"""1 / od['time', 'b']""", scope="""True""", - func_probabilities=lambda x: x / sum(x) + func_probabilities=lambda x: x / sum(x), + func_sampling=sample_weighted ) choices = choice_model.get_choice_set().u_choices probs = choice_model.selections.probabilities From abb9a49ed8744670219c4cd76091589ba2eac8ac Mon Sep 17 00:00:00 2001 From: Theodore-Chatziioannou Date: Tue, 23 May 2023 15:58:36 +0000 Subject: [PATCH 10/14] typo --- examples/15_Advanced_Choice.ipynb | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/examples/15_Advanced_Choice.ipynb b/examples/15_Advanced_Choice.ipynb index babcf785..81d41236 100644 --- a/examples/15_Advanced_Choice.ipynb +++ b/examples/15_Advanced_Choice.ipynb @@ -43,7 +43,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 1, "id": "82e63bf7", "metadata": {}, "outputs": [], @@ -229,7 +229,7 @@ "id": "1f6c39ea", "metadata": {}, "source": [ - "The `od` objects holds origin-destination data, for example travel time and travel distance between each origin and destination, for each travel mode:" + "The `od` object holds origin-destination data, for example travel time and travel distance between each origin and destination, for each travel mode:" ] }, { @@ -329,7 +329,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 8, "id": "760e9d2d", "metadata": {}, "outputs": [ @@ -338,7 +338,7 @@ "output_type": "stream", "text": [ "INFO:pam.planner.choice:Updated model configuration\n", - "INFO:pam.planner.choice:ChoiceConfiguration(u=None, scope=None, func_probabilities=, func_sampling=)\n" + "INFO:pam.planner.choice:ChoiceConfiguration(u=None, scope=None, func_probabilities=, func_sampling=)\n" ] } ], @@ -362,7 +362,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 9, "id": "f368c9df", "metadata": { "scrolled": false @@ -373,14 +373,14 @@ "output_type": "stream", "text": [ "INFO:pam.planner.choice:Updated model configuration\n", - "INFO:pam.planner.choice:ChoiceConfiguration(u=\"[0,-1]+(np.array([-2,0])*(person.attributes['subpopulation']=='poor'))+([-0.05,-0.07]*od['time',person.home.area])+(0.4*np.log(zones['jobs'].values))\\n\", scope=\"act.act=='work'\", func_probabilities=, func_sampling=)\n" + "INFO:pam.planner.choice:ChoiceConfiguration(u=\"[0,-1]+(np.array([0,2])*(person.attributes['subpopulation']=='poor'))+([-0.05,-0.07]*od['time',person.home.area])+(0.4*np.log(zones['jobs'].values))\\n\", scope=\"act.act=='work'\", func_probabilities=, func_sampling=)\n" ] } ], "source": [ "scope = \"act.act=='work'\"\n", "asc = [0, -1] # one value for each mode, 0->car, -1->\n", - "asc_shift_poor = [-2, 0] # one value for each mode\n", + "asc_shift_poor = [0, 2] # one value for each mode\n", "beta_time = [-0.05, -0.07] # one value for each mode\n", "beta_zones = 0.4\n", "u = f\"\"\" \\\n", @@ -404,7 +404,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 10, "id": "48fc5154", "metadata": {}, "outputs": [ @@ -413,16 +413,16 @@ "output_type": "stream", "text": [ "Activities in scope: \n", - " [ChoiceIdx(pid='chris', hid='chris', seq=1, act=), ChoiceIdx(pid='fatema', hid='fatema', seq=1, act=), ChoiceIdx(pid='fred', hid='fred', seq=3, act=), ChoiceIdx(pid='gerry', hid='gerry', seq=3, act=), ChoiceIdx(pid='nick', hid='nick', seq=1, act=)]\n", + " [ChoiceIdx(pid='chris', hid='chris', seq=1, act=), ChoiceIdx(pid='fatema', hid='fatema', seq=1, act=), ChoiceIdx(pid='fred', hid='fred', seq=3, act=), ChoiceIdx(pid='gerry', hid='gerry', seq=3, act=), ChoiceIdx(pid='nick', hid='nick', seq=1, act=)]\n", "\n", "Alternatives: \n", " [ChoiceLabel(destination='a', mode='car'), ChoiceLabel(destination='a', mode='bus'), ChoiceLabel(destination='b', mode='car'), ChoiceLabel(destination='b', mode='bus')]\n", "\n", "Choice set utilities: \n", " [[ 0.84206807 -0.98067305 -0.15793193 -2.03067305]\n", - " [-1.15793193 -0.98067305 -2.15793193 -2.03067305]\n", - " [-1.15793193 -0.98067305 -2.15793193 -2.03067305]\n", - " [-1.15793193 -0.98067305 -2.15793193 -2.03067305]\n", + " [ 0.84206807 1.01932695 -0.15793193 -0.03067305]\n", + " [ 0.84206807 1.01932695 -0.15793193 -0.03067305]\n", + " [ 0.84206807 1.01932695 -0.15793193 -0.03067305]\n", " [ 0.84206807 -0.98067305 -0.15793193 -2.03067305]]\n" ] } @@ -444,7 +444,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 11, "id": "e26b40d1", "metadata": { "scrolled": false @@ -456,7 +456,7 @@ "text": [ "INFO:pam.planner.choice:Applying choice model...\n", "INFO:pam.planner.choice:Configuration: \n", - "ChoiceConfiguration(u=\"[0,-1]+(np.array([-2,0])*(person.attributes['subpopulation']=='poor'))+([-0.05,-0.07]*od['time',person.home.area])+(0.4*np.log(zones['jobs'].values))\\n\", scope=\"act.act=='work'\", func_probabilities=, func_sampling=)\n", + "ChoiceConfiguration(u=\"[0,-1]+(np.array([0,2])*(person.attributes['subpopulation']=='poor'))+([-0.05,-0.07]*od['time',person.home.area])+(0.4*np.log(zones['jobs'].values))\\n\", scope=\"act.act=='work'\", func_probabilities=, func_sampling=)\n", "INFO:pam.planner.choice:Choice model application complete.\n" ] }, @@ -484,7 +484,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 12, "id": "4bfa1fee", "metadata": {}, "outputs": [ From 6124ab01996839e3e193fbe73d13510e761a6aab Mon Sep 17 00:00:00 2001 From: Theodore-Chatziioannou Date: Tue, 23 May 2023 18:10:49 +0000 Subject: [PATCH 11/14] add failing test --- tests/test_24_planner_choice.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_24_planner_choice.py b/tests/test_24_planner_choice.py index f4044cd5..7182bec7 100644 --- a/tests/test_24_planner_choice.py +++ b/tests/test_24_planner_choice.py @@ -160,3 +160,28 @@ def test_model_checks_config_requirements(mocker, choice_model_mnl): choice_model_mnl.get_choice_set() ChoiceConfiguration.validate.assert_called_once_with(['u', 'scope']) + +def test_utility_calculation(choice_model_mnl): + scope = "act.act=='work'" + asc = [0, -1] + asc_shift_poor = [0, 2] + beta_time = [-0.05, -0.07] + beta_zones = 0.4 + u = f""" \ + {asc} + \ + (np.array({asc_shift_poor}) * (person.attributes['subpopulation']=='poor')) + \ + ({beta_time} * od['time', person.home.area]) + \ + ({beta_zones} * np.log(zones.jobs)) + """ + # ({beta_zones} * np.log(zones['jobs'].values[:, np.newaxis])) + choice_model_mnl.configure(u=u, scope=scope) + + np.testing.assert_almost_equal( + np.array([ + 0.8420680743952365, + -1.2579319256047636, + 0.11932694661921461, + -2.0306730533807857 + ]), + choice_model_mnl.get_choice_set().u_choices[0], + ) \ No newline at end of file From 70b9c7d4e737a71d405932b6f432bbfe7e7ba258 Mon Sep 17 00:00:00 2001 From: Theodore-Chatziioannou Date: Wed, 24 May 2023 09:28:50 +0000 Subject: [PATCH 12/14] fix zone dimension bug --- examples/15_Advanced_Choice.ipynb | 50 +++++++++++++++---------------- pam/planner/choice.py | 34 +++++++++++++-------- pam/planner/zones.py | 2 +- tests/test_23_planner_zones.py | 14 ++------- tests/test_24_planner_choice.py | 2 +- 5 files changed, 51 insertions(+), 51 deletions(-) diff --git a/examples/15_Advanced_Choice.ipynb b/examples/15_Advanced_Choice.ipynb index 81d41236..8c03f94b 100644 --- a/examples/15_Advanced_Choice.ipynb +++ b/examples/15_Advanced_Choice.ipynb @@ -43,7 +43,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 2, "id": "82e63bf7", "metadata": {}, "outputs": [], @@ -65,7 +65,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 3, "id": "4154e47e", "metadata": {}, "outputs": [], @@ -92,7 +92,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 4, "id": "9542e3c7", "metadata": {}, "outputs": [], @@ -106,7 +106,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 5, "id": "e1163184", "metadata": {}, "outputs": [ @@ -152,7 +152,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 6, "id": "3081485c", "metadata": {}, "outputs": [ @@ -208,7 +208,7 @@ "b 200 1" ] }, - "execution_count": 5, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } @@ -234,7 +234,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 7, "id": "51c0dc76", "metadata": {}, "outputs": [ @@ -263,7 +263,7 @@ "--------------------------------------------------" ] }, - "execution_count": 6, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } @@ -300,7 +300,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 8, "id": "243511f7", "metadata": {}, "outputs": [ @@ -310,7 +310,7 @@ "45" ] }, - "execution_count": 7, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } @@ -329,7 +329,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 9, "id": "760e9d2d", "metadata": {}, "outputs": [ @@ -338,7 +338,7 @@ "output_type": "stream", "text": [ "INFO:pam.planner.choice:Updated model configuration\n", - "INFO:pam.planner.choice:ChoiceConfiguration(u=None, scope=None, func_probabilities=, func_sampling=)\n" + "INFO:pam.planner.choice:ChoiceConfiguration(u=None, scope=None, func_probabilities=, func_sampling=)\n" ] } ], @@ -362,7 +362,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 12, "id": "f368c9df", "metadata": { "scrolled": false @@ -373,7 +373,7 @@ "output_type": "stream", "text": [ "INFO:pam.planner.choice:Updated model configuration\n", - "INFO:pam.planner.choice:ChoiceConfiguration(u=\"[0,-1]+(np.array([0,2])*(person.attributes['subpopulation']=='poor'))+([-0.05,-0.07]*od['time',person.home.area])+(0.4*np.log(zones['jobs'].values))\\n\", scope=\"act.act=='work'\", func_probabilities=, func_sampling=)\n" + "INFO:pam.planner.choice:ChoiceConfiguration(u=\"[0,-1]+(np.array([0,2])*(person.attributes['subpopulation']=='poor'))+([-0.05,-0.07]*od['time',person.home.area])+(0.4*np.log(zones['jobs']))\\n\", scope=\"act.act=='work'\", func_probabilities=, func_sampling=)\n" ] } ], @@ -387,7 +387,7 @@ " {asc} + \\\n", " (np.array({asc_shift_poor}) * (person.attributes['subpopulation']=='poor')) + \\\n", " ({beta_time} * od['time', person.home.area]) + \\\n", - " ({beta_zones} * np.log(zones['jobs'].values))\n", + " ({beta_zones} * np.log(zones['jobs']))\n", "\"\"\"\n", "\n", "planner.configure(u=u, scope=scope)\n", @@ -404,7 +404,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 13, "id": "48fc5154", "metadata": {}, "outputs": [ @@ -413,17 +413,17 @@ "output_type": "stream", "text": [ "Activities in scope: \n", - " [ChoiceIdx(pid='chris', hid='chris', seq=1, act=), ChoiceIdx(pid='fatema', hid='fatema', seq=1, act=), ChoiceIdx(pid='fred', hid='fred', seq=3, act=), ChoiceIdx(pid='gerry', hid='gerry', seq=3, act=), ChoiceIdx(pid='nick', hid='nick', seq=1, act=)]\n", + " [ChoiceIdx(pid='chris', hid='chris', seq=1, act=), ChoiceIdx(pid='fatema', hid='fatema', seq=1, act=), ChoiceIdx(pid='fred', hid='fred', seq=3, act=), ChoiceIdx(pid='gerry', hid='gerry', seq=3, act=), ChoiceIdx(pid='nick', hid='nick', seq=1, act=)]\n", "\n", "Alternatives: \n", " [ChoiceLabel(destination='a', mode='car'), ChoiceLabel(destination='a', mode='bus'), ChoiceLabel(destination='b', mode='car'), ChoiceLabel(destination='b', mode='bus')]\n", "\n", "Choice set utilities: \n", - " [[ 0.84206807 -0.98067305 -0.15793193 -2.03067305]\n", - " [ 0.84206807 1.01932695 -0.15793193 -0.03067305]\n", - " [ 0.84206807 1.01932695 -0.15793193 -0.03067305]\n", - " [ 0.84206807 1.01932695 -0.15793193 -0.03067305]\n", - " [ 0.84206807 -0.98067305 -0.15793193 -2.03067305]]\n" + " [[ 0.84206807 -1.25793193 0.11932695 -2.03067305]\n", + " [ 0.84206807 0.74206807 0.11932695 -0.03067305]\n", + " [ 0.84206807 0.74206807 0.11932695 -0.03067305]\n", + " [ 0.84206807 0.74206807 0.11932695 -0.03067305]\n", + " [ 0.84206807 -1.25793193 0.11932695 -2.03067305]]\n" ] } ], @@ -444,7 +444,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 14, "id": "e26b40d1", "metadata": { "scrolled": false @@ -456,7 +456,7 @@ "text": [ "INFO:pam.planner.choice:Applying choice model...\n", "INFO:pam.planner.choice:Configuration: \n", - "ChoiceConfiguration(u=\"[0,-1]+(np.array([0,2])*(person.attributes['subpopulation']=='poor'))+([-0.05,-0.07]*od['time',person.home.area])+(0.4*np.log(zones['jobs'].values))\\n\", scope=\"act.act=='work'\", func_probabilities=, func_sampling=)\n", + "ChoiceConfiguration(u=\"[0,-1]+(np.array([0,2])*(person.attributes['subpopulation']=='poor'))+([-0.05,-0.07]*od['time',person.home.area])+(0.4*np.log(zones['jobs']))\\n\", scope=\"act.act=='work'\", func_probabilities=, func_sampling=)\n", "INFO:pam.planner.choice:Choice model application complete.\n" ] }, @@ -484,7 +484,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 15, "id": "4bfa1fee", "metadata": {}, "outputs": [ diff --git a/pam/planner/choice.py b/pam/planner/choice.py index c06f10fd..0673bf07 100644 --- a/pam/planner/choice.py +++ b/pam/planner/choice.py @@ -1,19 +1,22 @@ """ Choice models for activity synthesis """ +from copy import deepcopy from dataclasses import dataclass import itertools import logging -from typing import Optional, List, NamedTuple, Callable -import pandas as pd +from typing import Optional, List, NamedTuple, Callable, Union + import numpy as np +import pandas as pd + +from pam.activity import Activity, Leg +from pam.core import Population +from pam.operations.cropping import link_population from pam.planner.od import OD +from pam.planner.zones import Zones from pam.planner.utils_planner import calculate_mnl_probabilities, sample_weighted, \ apply_mode_to_home_chain -from pam.core import Population -from pam.activity import Activity, Leg -from pam.operations.cropping import link_population - class ChoiceLabel(NamedTuple): """ Destination and mode choice labels of a selected option """ @@ -94,7 +97,7 @@ class ChoiceConfiguration: func_probabilities: Optional[Callable] = None func_sampling: Optional[Callable] = None - def validate(self, vars:List[str]) -> None: + def validate(self, vars: List[str]) -> None: """ Return an error if a value has not been set """ @@ -109,7 +112,7 @@ def __init__( self, population: Population, od: OD, - zones: pd.DataFrame # TODO: switch to pam.planner.zones.Zones object + zones: Union[pd.DataFrame, Zones] ) -> None: """ Choice model interface. @@ -122,10 +125,18 @@ def __init__( self.population = population link_population(self.population) self.od = od - self.zones = zones.loc[od.labels.destination_zones].copy() + self.zones = self.parse_zone_data(zones) + self.zones.data = self.zones.data.loc[od.labels.destination_zones] self.configuration = ChoiceConfiguration() self._selections = None + @staticmethod + def parse_zone_data(zones: Union[pd.DataFrame, Zones]) -> Zones: + if isinstance(zones, Zones): + return deepcopy(zones) + elif isinstance(zones, pd.DataFrame): + return Zones(data=zones.copy()) + def configure(self, **kwargs) -> None: """ Specify the model. @@ -139,7 +150,6 @@ def configure(self, **kwargs) -> None: self.logger.info('Updated model configuration') self.logger.info(self.configuration) - def apply(self, apply_location=True, apply_mode=True, once_per_agent=True, apply_mode_to='chain'): """ @@ -249,6 +259,6 @@ class ChoiceMNL(ChoiceModel): def __init__(self, population: Population, od: OD, zones: pd.DataFrame) -> None: super().__init__(population, od, zones) self.configure( - func_probabilities = calculate_mnl_probabilities, - func_sampling = sample_weighted + func_probabilities=calculate_mnl_probabilities, + func_sampling=sample_weighted ) diff --git a/pam/planner/zones.py b/pam/planner/zones.py index c38ab005..d7f3d2bd 100644 --- a/pam/planner/zones.py +++ b/pam/planner/zones.py @@ -16,7 +16,7 @@ def __init__( self.data = data def __getattr__(self, __name: str) -> np.array: - return self.data[__name].values + return self.data[__name].values[:, np.newaxis] def __getitem__(self, __name: str) -> np.array: return self.__getattr__(__name) diff --git a/tests/test_23_planner_zones.py b/tests/test_23_planner_zones.py index 14dd8b3d..abd67e7b 100644 --- a/tests/test_23_planner_zones.py +++ b/tests/test_23_planner_zones.py @@ -19,17 +19,7 @@ def data_zones(): def zones(data_zones): return Zones(data=data_zones) -def test_get_zone_data(data_zones): - np.testing.assert_equal(data_zones.loc['b'].values, np.array([200, 1])) - - -def test_get_variable_data(data_zones): - np.testing.assert_equal(data_zones['jobs'], np.array([100, 200])) - - -def test_get_values(data_zones): - assert data_zones.loc['b', 'jobs'] == 200 - assert data_zones.loc['b']['jobs'] == 200 def test_get_variable(zones): - np.testing.assert_equal(zones.jobs, np.array([100, 200])) \ No newline at end of file + np.testing.assert_equal(zones.jobs, np.array([[100], [200]])) + np.testing.assert_equal(zones['jobs'], zones.jobs) \ No newline at end of file diff --git a/tests/test_24_planner_choice.py b/tests/test_24_planner_choice.py index 7182bec7..a9e48e96 100644 --- a/tests/test_24_planner_choice.py +++ b/tests/test_24_planner_choice.py @@ -50,7 +50,7 @@ def choice_model_mnl(population, od, data_zones) -> ChoiceModel: def test_zones_are_aligned(population, od, data_zones): choice_model = ChoiceModel(population, od, data_zones.loc[['b', 'a']]) zones_destination = choice_model.od.labels.destination_zones - zones_index = list(choice_model.zones.index) + zones_index = list(choice_model.zones.data.index) assert zones_destination == zones_index From 1429e53f688d7833898d842fbc1ad47b7374fefa Mon Sep 17 00:00:00 2001 From: Theodore-Chatziioannou Date: Wed, 24 May 2023 13:31:19 +0000 Subject: [PATCH 13/14] od factory --- examples/15_Advanced_Choice.ipynb | 89 ++++++++++++++----------------- pam/planner/choice.py | 2 +- pam/planner/od.py | 81 +++++++++++++++++++++++++--- tests/test_22_planner_od.py | 38 ++++++++++++- tests/test_24_planner_choice.py | 9 ++-- 5 files changed, 157 insertions(+), 62 deletions(-) diff --git a/examples/15_Advanced_Choice.ipynb b/examples/15_Advanced_Choice.ipynb index 8c03f94b..5c86a39e 100644 --- a/examples/15_Advanced_Choice.ipynb +++ b/examples/15_Advanced_Choice.ipynb @@ -43,7 +43,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 1, "id": "82e63bf7", "metadata": {}, "outputs": [], @@ -52,7 +52,7 @@ "from pam.read import read_matsim\n", "from pam.planner import choice\n", "from pam.operations.cropping import link_population\n", - "from pam.planner.od import OD, Labels\n", + "from pam.planner.od import OD, Labels, ODMatrix, ODFactory\n", "import random\n", "import numpy as np\n", "import os\n", @@ -65,7 +65,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 2, "id": "4154e47e", "metadata": {}, "outputs": [], @@ -92,7 +92,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 3, "id": "9542e3c7", "metadata": {}, "outputs": [], @@ -106,7 +106,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 4, "id": "e1163184", "metadata": {}, "outputs": [ @@ -152,7 +152,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 5, "id": "3081485c", "metadata": {}, "outputs": [ @@ -208,7 +208,7 @@ "b 200 1" ] }, - "execution_count": 6, + "execution_count": 5, "metadata": {}, "output_type": "execute_result" } @@ -234,8 +234,8 @@ }, { "cell_type": "code", - "execution_count": 7, - "id": "51c0dc76", + "execution_count": 6, + "id": "ddafc60f", "metadata": {}, "outputs": [ { @@ -243,50 +243,39 @@ "text/plain": [ "Origin-destination dataset \n", "--------------------------------------------------\n", - "Labels(vars=['time', 'distance'], origin_zones=['a', 'b'], destination_zones=['a', 'b'], mode=['car', 'bus'])\n", + "Labels(vars=['time', 'distance'], origin_zones=('a', 'b'), destination_zones=('a', 'b'), mode=['car', 'bus'])\n", "--------------------------------------------------\n", "time - car:\n", - "[[20 40]\n", - " [40 20]]\n", + "[[20. 40.]\n", + " [40. 20.]]\n", "--------------------------------------------------\n", "time - bus:\n", - "[[30 45]\n", - " [45 30]]\n", + "[[30. 45.]\n", + " [45. 30.]]\n", "--------------------------------------------------\n", "distance - car:\n", - "[[5 8]\n", - " [8 5]]\n", + "[[5. 8.]\n", + " [8. 5.]]\n", "--------------------------------------------------\n", "distance - bus:\n", - "[[5 9]\n", - " [9 5]]\n", + "[[5. 9.]\n", + " [9. 5.]]\n", "--------------------------------------------------" ] }, - "execution_count": 7, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "data_od = np.array(\n", - " [[[[20, 30], [40, 45]], [[40, 45], [20, 30]]],\n", - " [[[5, 5], [8, 9]], [[8, 9], [5, 5]]]]\n", - " )\n", - "\n", - "labels = {\n", - " 'mode': ['car', 'bus'],\n", - " 'vars': ['time', 'distance'],\n", - " 'origin_zones': ['a', 'b'],\n", - " 'destination_zones': ['a', 'b']\n", - "}\n", - "\n", - "\n", - "od = OD(\n", - " data=data_od,\n", - " labels=labels\n", - ")\n", - "\n", + "zone_labels = ('a', 'b')\n", + "od = ODFactory.from_matrices([\n", + " ODMatrix('time', 'car', zone_labels, zone_labels, np.array([[20, 40], [40, 20]])),\n", + " ODMatrix('time', 'bus', zone_labels, zone_labels, np.array([[30, 45], [45, 30]])),\n", + " ODMatrix('distance', 'car', zone_labels, zone_labels, np.array([[5, 8], [8, 5]])),\n", + " ODMatrix('distance', 'bus', zone_labels, zone_labels, np.array([[5, 9], [9, 5]])) \n", + "])\n", "od" ] }, @@ -295,22 +284,22 @@ "id": "55081a23", "metadata": {}, "source": [ - "The dimensions of the `od` objects are (in order): `variables`, `origin zone`, `destination zone`, and `mode`. It can be sliced using the respective labels under `od.labels`, for example:" + "The dimensions of the `od` object are always (in order): `variables`, `origin zone`, `destination zone`, and `mode`. It can be sliced using the respective labels under `od.labels`, for example:" ] }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 7, "id": "243511f7", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "45" + "45.0" ] }, - "execution_count": 8, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } @@ -329,7 +318,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 8, "id": "760e9d2d", "metadata": {}, "outputs": [ @@ -338,7 +327,7 @@ "output_type": "stream", "text": [ "INFO:pam.planner.choice:Updated model configuration\n", - "INFO:pam.planner.choice:ChoiceConfiguration(u=None, scope=None, func_probabilities=, func_sampling=)\n" + "INFO:pam.planner.choice:ChoiceConfiguration(u=None, scope=None, func_probabilities=, func_sampling=)\n" ] } ], @@ -362,7 +351,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 9, "id": "f368c9df", "metadata": { "scrolled": false @@ -373,7 +362,7 @@ "output_type": "stream", "text": [ "INFO:pam.planner.choice:Updated model configuration\n", - "INFO:pam.planner.choice:ChoiceConfiguration(u=\"[0,-1]+(np.array([0,2])*(person.attributes['subpopulation']=='poor'))+([-0.05,-0.07]*od['time',person.home.area])+(0.4*np.log(zones['jobs']))\\n\", scope=\"act.act=='work'\", func_probabilities=, func_sampling=)\n" + "INFO:pam.planner.choice:ChoiceConfiguration(u=\"[0,-1]+(np.array([0,2])*(person.attributes['subpopulation']=='poor'))+([-0.05,-0.07]*od['time',person.home.area])+(0.4*np.log(zones['jobs']))\\n\", scope=\"act.act=='work'\", func_probabilities=, func_sampling=)\n" ] } ], @@ -404,7 +393,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 10, "id": "48fc5154", "metadata": {}, "outputs": [ @@ -413,7 +402,7 @@ "output_type": "stream", "text": [ "Activities in scope: \n", - " [ChoiceIdx(pid='chris', hid='chris', seq=1, act=), ChoiceIdx(pid='fatema', hid='fatema', seq=1, act=), ChoiceIdx(pid='fred', hid='fred', seq=3, act=), ChoiceIdx(pid='gerry', hid='gerry', seq=3, act=), ChoiceIdx(pid='nick', hid='nick', seq=1, act=)]\n", + " [ChoiceIdx(pid='chris', hid='chris', seq=1, act=), ChoiceIdx(pid='fatema', hid='fatema', seq=1, act=), ChoiceIdx(pid='fred', hid='fred', seq=3, act=), ChoiceIdx(pid='gerry', hid='gerry', seq=3, act=), ChoiceIdx(pid='nick', hid='nick', seq=1, act=)]\n", "\n", "Alternatives: \n", " [ChoiceLabel(destination='a', mode='car'), ChoiceLabel(destination='a', mode='bus'), ChoiceLabel(destination='b', mode='car'), ChoiceLabel(destination='b', mode='bus')]\n", @@ -444,7 +433,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 11, "id": "e26b40d1", "metadata": { "scrolled": false @@ -456,7 +445,7 @@ "text": [ "INFO:pam.planner.choice:Applying choice model...\n", "INFO:pam.planner.choice:Configuration: \n", - "ChoiceConfiguration(u=\"[0,-1]+(np.array([0,2])*(person.attributes['subpopulation']=='poor'))+([-0.05,-0.07]*od['time',person.home.area])+(0.4*np.log(zones['jobs']))\\n\", scope=\"act.act=='work'\", func_probabilities=, func_sampling=)\n", + "ChoiceConfiguration(u=\"[0,-1]+(np.array([0,2])*(person.attributes['subpopulation']=='poor'))+([-0.05,-0.07]*od['time',person.home.area])+(0.4*np.log(zones['jobs']))\\n\", scope=\"act.act=='work'\", func_probabilities=, func_sampling=)\n", "INFO:pam.planner.choice:Choice model application complete.\n" ] }, @@ -484,7 +473,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 12, "id": "4bfa1fee", "metadata": {}, "outputs": [ diff --git a/pam/planner/choice.py b/pam/planner/choice.py index 0673bf07..e05f2346 100644 --- a/pam/planner/choice.py +++ b/pam/planner/choice.py @@ -126,7 +126,7 @@ def __init__( link_population(self.population) self.od = od self.zones = self.parse_zone_data(zones) - self.zones.data = self.zones.data.loc[od.labels.destination_zones] + self.zones.data = self.zones.data.loc[list(od.labels.destination_zones)] self.configuration = ChoiceConfiguration() self._selections = None diff --git a/pam/planner/od.py b/pam/planner/od.py index cc5aff14..ce8f42f9 100644 --- a/pam/planner/od.py +++ b/pam/planner/od.py @@ -1,9 +1,12 @@ """ Manages origin-destination data required by the planner module. """ -import numpy as np +import itertools from typing import Union, Optional, List, NamedTuple +import numpy as np +import pandas as pd + class Labels(NamedTuple): """ Data labels for the origin-destination dataset """ @@ -17,7 +20,6 @@ class OD: """ Holds origin-destination matrices for a number of modes and variables. """ - dimensions = ['mode', 'variable', 'origin', 'destination'] def __init__( self, @@ -26,10 +28,10 @@ def __init__( ) -> None: """ :param data: A multi-dimensional numpy array of the origin-destination data. - - First dimension: mode (ie car, bus, etc) - - Second dimension: variable (ie travel time, distance, etc) - - Third dimension: origin zone - - Fourth dimension: destination zone + - First dimension: variable (ie travel time, distance, etc) + - Second dimension: origin zone + - Third dimension: destination zone + - Fourth dimension: mode (ie car, bus, etc) """ self.data = data self.labels = self.parse_labels(labels) @@ -82,3 +84,70 @@ def __repr__(self) -> str: r += f'{var} - {trmode}:\n' r += f'{self[var, :, :, trmode].__str__()}\n{divider}' return r + + +class ODMatrix(NamedTuple): + var: str + mode: str + origin_zones: tuple + destination_zones: tuple + matrix: np.array + + +class ODFactory: + + @classmethod + def from_matrices(cls, matrices: List[ODMatrix]) -> OD: + """ + Creates an OD instance from a list of ODMatrices + """ + # collect dimensions + labels = cls.prepare_labels(matrices) + + cls.check(matrices, labels) + + # create ndarray + od = np.zeros(shape=[len(x) for x in labels]) + for mat in matrices: + od[ + labels.vars.index(mat.var), + labels.mode.index(mat.mode) + ] = mat.matrix + + # move mode to last dimension + od = np.moveaxis(od, 1, 3) + + return OD(data=od, labels=labels) + + @staticmethod + def prepare_labels(matrices: List[ODMatrix]) -> Labels: + labels = Labels( + vars=list(pd.unique([mat.var for mat in matrices])), + origin_zones=matrices[0].origin_zones, + destination_zones=matrices[0].destination_zones, + mode=list(pd.unique([mat.mode for mat in matrices])), + ) + return labels + + @staticmethod + def check(matrices: List[ODMatrix], labels: Labels) -> None: + # all matrices follow the same zoning system and are equal size + for mat in matrices: + assert mat.origin_zones == labels.origin_zones, \ + 'Please check zone labels' + assert mat.destination_zones == labels.destination_zones, \ + 'Please check zone labels' + assert mat.matrix.shape == matrices[0].matrix.shape, \ + 'Please check matrix dimensions' + + # all possible combinations are provided + combinations_matrices = [(var, trmode) + for (var, trmode, *others) in matrices] + combinations_labels = list(itertools.product(labels.vars, labels.mode)) + for combination in combinations_labels: + assert combination in combinations_matrices, \ + f'Combination {combination} missing from the input matrices' + + # no duplicate combinations + assert len(combinations_matrices) == len(set(combinations_matrices)), \ + 'No duplicate keys are allowed' diff --git a/tests/test_22_planner_od.py b/tests/test_22_planner_od.py index 81502879..a538daed 100644 --- a/tests/test_22_planner_od.py +++ b/tests/test_22_planner_od.py @@ -1,5 +1,5 @@ import pytest -from pam.planner.od import OD, Labels +from pam.planner.od import OD, Labels, ODMatrix, ODFactory import pandas as pd import numpy as np from copy import deepcopy @@ -33,6 +33,16 @@ def od(data_od, labels): ) return od +@pytest.fixture +def od_matrices(): + zone_labels = ['a', 'b'] + matrices = [ + ODMatrix('time', 'car', zone_labels, zone_labels, np.array([[20, 40], [40, 20]])), + ODMatrix('time', 'bus', zone_labels, zone_labels, np.array([[30, 45], [45, 30]])), + ODMatrix('distance', 'car', zone_labels, zone_labels, np.array([[5, 8], [8, 5]])), + ODMatrix('distance', 'bus', zone_labels, zone_labels, np.array([[5, 9], [9, 5]])) + ] + return matrices def test_label_type_is_parsed_correctly(labels): assert type(OD.parse_labels(labels)) is Labels @@ -76,3 +86,29 @@ def test_matrix_dimensions_stay_the_same(od): """ assert od.labels._fields == tuple( ['vars', 'origin_zones', 'destination_zones', 'mode']) + + +def test_create_od_from_matrices(od_matrices, od): + od_from_matrices = ODFactory.from_matrices(od_matrices) + np.testing.assert_equal(od_from_matrices.data, od.data) + assert od_from_matrices.labels == od.labels + + +def test_od_factory_inconsistent_inputs_raise_error(od_matrices): + labels = Labels( + vars=['time', 'distance'], + origin_zones=('a', 'b'), + destination_zones=('a', 'b'), + mode=['car', 'bus'] + ) + # duplicate key + with pytest.raises(AssertionError): + ODFactory.check(od_matrices+[od_matrices[0]], labels) + + # combination missing + with pytest.raises(AssertionError): + ODFactory.check(od_matrices[:-1], labels) + + # inconsistent zoning + with pytest.raises(AssertionError): + ODFactory.check(od_matrices[:-1], labels) \ No newline at end of file diff --git a/tests/test_24_planner_choice.py b/tests/test_24_planner_choice.py index a9e48e96..b2e08ff7 100644 --- a/tests/test_24_planner_choice.py +++ b/tests/test_24_planner_choice.py @@ -161,11 +161,12 @@ def test_model_checks_config_requirements(mocker, choice_model_mnl): choice_model_mnl.get_choice_set() ChoiceConfiguration.validate.assert_called_once_with(['u', 'scope']) + def test_utility_calculation(choice_model_mnl): scope = "act.act=='work'" - asc = [0, -1] - asc_shift_poor = [0, 2] - beta_time = [-0.05, -0.07] + asc = [0, -1] + asc_shift_poor = [0, 2] + beta_time = [-0.05, -0.07] beta_zones = 0.4 u = f""" \ {asc} + \ @@ -184,4 +185,4 @@ def test_utility_calculation(choice_model_mnl): -2.0306730533807857 ]), choice_model_mnl.get_choice_set().u_choices[0], - ) \ No newline at end of file + ) From 9b4600db27375e2fc1a2e5acd511ce7db9f3e1cf Mon Sep 17 00:00:00 2001 From: Theodore-Chatziioannou Date: Wed, 24 May 2023 14:22:11 +0000 Subject: [PATCH 14/14] renaming --- examples/15_Advanced_Choice.ipynb | 22 +++++++++---------- pam/planner/{choice.py => choice_location.py} | 2 +- ....py => test_24_planner_choice_location.py} | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) rename pam/planner/{choice.py => choice_location.py} (99%) rename tests/{test_24_planner_choice.py => test_24_planner_choice_location.py} (98%) diff --git a/examples/15_Advanced_Choice.ipynb b/examples/15_Advanced_Choice.ipynb index 5c86a39e..7bd413c7 100644 --- a/examples/15_Advanced_Choice.ipynb +++ b/examples/15_Advanced_Choice.ipynb @@ -50,7 +50,7 @@ "source": [ "import pandas as pd\n", "from pam.read import read_matsim\n", - "from pam.planner import choice\n", + "from pam.planner import choice_location as choice\n", "from pam.operations.cropping import link_population\n", "from pam.planner.od import OD, Labels, ODMatrix, ODFactory\n", "import random\n", @@ -235,7 +235,7 @@ { "cell_type": "code", "execution_count": 6, - "id": "ddafc60f", + "id": "15027351", "metadata": {}, "outputs": [ { @@ -326,8 +326,8 @@ "name": "stderr", "output_type": "stream", "text": [ - "INFO:pam.planner.choice:Updated model configuration\n", - "INFO:pam.planner.choice:ChoiceConfiguration(u=None, scope=None, func_probabilities=, func_sampling=)\n" + "INFO:pam.planner.choice_location:Updated model configuration\n", + "INFO:pam.planner.choice_location:ChoiceConfiguration(u=None, scope=None, func_probabilities=, func_sampling=)\n" ] } ], @@ -361,8 +361,8 @@ "name": "stderr", "output_type": "stream", "text": [ - "INFO:pam.planner.choice:Updated model configuration\n", - "INFO:pam.planner.choice:ChoiceConfiguration(u=\"[0,-1]+(np.array([0,2])*(person.attributes['subpopulation']=='poor'))+([-0.05,-0.07]*od['time',person.home.area])+(0.4*np.log(zones['jobs']))\\n\", scope=\"act.act=='work'\", func_probabilities=, func_sampling=)\n" + "INFO:pam.planner.choice_location:Updated model configuration\n", + "INFO:pam.planner.choice_location:ChoiceConfiguration(u=\"[0,-1]+(np.array([0,2])*(person.attributes['subpopulation']=='poor'))+([-0.05,-0.07]*od['time',person.home.area])+(0.4*np.log(zones['jobs']))\\n\", scope=\"act.act=='work'\", func_probabilities=, func_sampling=)\n" ] } ], @@ -402,7 +402,7 @@ "output_type": "stream", "text": [ "Activities in scope: \n", - " [ChoiceIdx(pid='chris', hid='chris', seq=1, act=), ChoiceIdx(pid='fatema', hid='fatema', seq=1, act=), ChoiceIdx(pid='fred', hid='fred', seq=3, act=), ChoiceIdx(pid='gerry', hid='gerry', seq=3, act=), ChoiceIdx(pid='nick', hid='nick', seq=1, act=)]\n", + " [ChoiceIdx(pid='chris', hid='chris', seq=1, act=), ChoiceIdx(pid='fatema', hid='fatema', seq=1, act=), ChoiceIdx(pid='fred', hid='fred', seq=3, act=), ChoiceIdx(pid='gerry', hid='gerry', seq=3, act=), ChoiceIdx(pid='nick', hid='nick', seq=1, act=)]\n", "\n", "Alternatives: \n", " [ChoiceLabel(destination='a', mode='car'), ChoiceLabel(destination='a', mode='bus'), ChoiceLabel(destination='b', mode='car'), ChoiceLabel(destination='b', mode='bus')]\n", @@ -443,10 +443,10 @@ "name": "stderr", "output_type": "stream", "text": [ - "INFO:pam.planner.choice:Applying choice model...\n", - "INFO:pam.planner.choice:Configuration: \n", - "ChoiceConfiguration(u=\"[0,-1]+(np.array([0,2])*(person.attributes['subpopulation']=='poor'))+([-0.05,-0.07]*od['time',person.home.area])+(0.4*np.log(zones['jobs']))\\n\", scope=\"act.act=='work'\", func_probabilities=, func_sampling=)\n", - "INFO:pam.planner.choice:Choice model application complete.\n" + "INFO:pam.planner.choice_location:Applying choice model...\n", + "INFO:pam.planner.choice_location:Configuration: \n", + "ChoiceConfiguration(u=\"[0,-1]+(np.array([0,2])*(person.attributes['subpopulation']=='poor'))+([-0.05,-0.07]*od['time',person.home.area])+(0.4*np.log(zones['jobs']))\\n\", scope=\"act.act=='work'\", func_probabilities=, func_sampling=)\n", + "INFO:pam.planner.choice_location:Choice model application complete.\n" ] }, { diff --git a/pam/planner/choice.py b/pam/planner/choice_location.py similarity index 99% rename from pam/planner/choice.py rename to pam/planner/choice_location.py index e05f2346..f91649fc 100644 --- a/pam/planner/choice.py +++ b/pam/planner/choice_location.py @@ -1,5 +1,5 @@ """ -Choice models for activity synthesis +Location and mode choice models for activity modelling """ from copy import deepcopy from dataclasses import dataclass diff --git a/tests/test_24_planner_choice.py b/tests/test_24_planner_choice_location.py similarity index 98% rename from tests/test_24_planner_choice.py rename to tests/test_24_planner_choice_location.py index b2e08ff7..33ab4d66 100644 --- a/tests/test_24_planner_choice.py +++ b/tests/test_24_planner_choice_location.py @@ -3,7 +3,7 @@ import random from tests.test_22_planner_od import data_od, labels, od from tests.test_23_planner_zones import data_zones -from pam.planner.choice import ChoiceModel, ChoiceMNL, ChoiceSet, \ +from pam.planner.choice_location import ChoiceModel, ChoiceMNL, ChoiceSet, \ ChoiceConfiguration from pam.planner.utils_planner import sample_weighted import os