Source code for pulpo.utils.uncertainty.preparer

"""Import of the uncertainty data an impact ``X(s) = sum_e q_e sum_j b_ej s_j`` depends on.

Which parameters
----------------
Every entry ``b_ej`` of the intervention matrix on a flow ``e`` that the LCIA
method characterizes, and every characterization factor ``q_e`` of the method
on a flow that occurs in the intervention matrix. Nothing else can move ``X``:
an entry on an uncharacterized flow is multiplied by a CF of exactly zero, and
an entry that is zero in ``B`` is no coefficient at all. No contribution
filter is applied, so the parameter set does not depend on a reference
solution.

Technosphere exchanges are not parameters, as of now: ``A`` is deterministic
and any distribution the database declares for its entries is not read. The
closed-form moments and the reduced space both rely on that.

Declared and undeclared
-----------------------
A parameter is *declared* when the database or the method gives it a
distribution (``uncertainty_type > 0``) and *undeclared* otherwise. Undeclared
parameters are deterministic in every result (mean = amount, variance 0); they
are kept, under ``'undeclared'``, so that the screening of undeclared parameters
can rank them. ``uncertainty_type = 1`` ("no uncertainty") is a declaration of
an exact value and is kept under ``'declared'`` with variance 0.

The container (``UncertaintyData``) is a nested dict::

    {'If': {database: {'declared': {(e, j): spec}, 'undeclared': {(e, j): spec}}},
     'Cf': {method:   {'declared': {e: spec},      'undeclared': {e: spec}}}}

with ``spec`` a stats_arrays-style dict (``uncertainty_type``, ``amount``,
``loc``, ``scale``, ``shape``, ``minimum``, ``maximum``, ``negative``) and
``(e, j)`` the (flow row, process column) of the entry in ``B``.
"""

import math
from typing import Dict, List, Tuple, TypedDict, Union

import numpy as np
import pandas as pd
import scipy.sparse as sp
import stats_arrays


[docs] class UncertaintySpec(TypedDict, total=False): """One parameter's declared distribution, in stats_arrays' field names."""
[docs] uncertainty_type: int
[docs] amount: float
[docs] loc: float
[docs] scale: float
[docs] shape: float
[docs] minimum: float
[docs] maximum: float
[docs] negative: bool
[docs] ParamIndex = Union[Tuple[int, int], int]
[docs] class ParameterBlock(TypedDict, total=False): """The parameters of one database or method, split by whether they declare a distribution."""
[docs] declared: Dict[ParamIndex, UncertaintySpec]
[docs] undeclared: Dict[ParamIndex, UncertaintySpec]
[docs] class UncertaintyData(TypedDict, total=False): """The uncertainty data of an impact: ``'If'`` per database, ``'Cf'`` for the method."""
[docs] If: Dict[str, ParameterBlock] # one block per database
[docs] Cf: Dict[str, ParameterBlock] # one block, for the LCIA method
#: Families with closed-form moments and quantiles, and the fields each needs.
[docs] SUPPORTED_FAMILIES = { stats_arrays.NoUncertainty.id: (), stats_arrays.LognormalUncertainty.id: ('loc', 'scale'), stats_arrays.NormalUncertainty.id: ('loc', 'scale'), stats_arrays.UniformUncertainty.id: ('minimum', 'maximum'), stats_arrays.TriangularUncertainty.id: ('minimum', 'loc', 'maximum'), }
[docs] _SPEC_FIELDS = ('uncertainty_type', 'amount', 'loc', 'scale', 'shape', 'minimum', 'maximum', 'negative')
[docs] def _single_method(worker): methods = list(worker.method) if len(methods) != 1: raise ValueError("The uncertainty of an impact is imported for one LCIA method; the worker " f"has {len(methods)}. Pass method= with one of {methods}.") return methods[0]
[docs] def _record(row) -> UncertaintySpec: spec = {field: row[field] for field in _SPEC_FIELDS if field in row} spec['uncertainty_type'] = int(spec.get('uncertainty_type', 0)) spec['amount'] = float(spec['amount']) if 'negative' in spec: spec['negative'] = bool(spec['negative']) return spec
[docs] def _split(records): declared, undeclared = {}, {} for index, spec in records: (declared if spec['uncertainty_type'] > 0 else undeclared)[index] = spec return declared, undeclared
[docs] def import_declared(worker, method=None) -> UncertaintyData: """The parameters of the worker's impact and their declared distributions. Args: worker (PulpoOptimizer): a worker after ``get_lci_data()``. method (str, optional): the LCIA method; defaults to the worker's only one. Returns: UncertaintyData: see the module docstring. """ if worker.lci_data is None: raise ValueError("Call get_lci_data() before importing the uncertainty data.") method = method or _single_method(worker) lci = worker.lci_data databases = worker.database if isinstance(worker.database, list) else [worker.database] missing = [] if lci.get('intervention_params') is None: missing.append(f"the biosphere entries of {databases}") if (lci.get('characterization_params') or {}).get(method) is None: missing.append(f"the characterization factors of {method}") if missing: raise ValueError(f"No uncertainty parameters for {' and '.join(missing)}: Brightway provided no " "entries for them, or inconsistent ones. Check that the databases and the " "method are written and processed.") cf_params = pd.DataFrame(lci['characterization_params'][method]) if cf_params['row'].duplicated().any(): raise NotImplementedError(f"The method {method!r} characterizes a flow twice.") B = sp.csr_matrix(lci['intervention_matrix']) used_flows = set(np.flatnonzero(np.diff(B.indptr)).tolist()) cf_params = cf_params[cf_params['row'].isin(used_flows)] flows = set(cf_params['row'].astype(int).tolist()) params = pd.DataFrame(lci['intervention_params']) params = params[params['row'].isin(flows)] duplicated = params.duplicated(['row', 'col'], keep=False) if duplicated.any(): first = params[duplicated].iloc[0] raise NotImplementedError( f"{int(duplicated.sum())} exchanges share a (flow, process) entry of B with another " f"exchange, e.g. flow {int(first['row'])} in process {int(first['col'])}; their sum " "has no single declared distribution.") process_db = {j: key[0] for key, j in lci['process_map'].items()} data: UncertaintyData = {'If': {db: {'declared': {}, 'undeclared': {}} for db in databases}, 'Cf': {}} records = {db: [] for db in databases} for row in params.to_dict('records'): index = (int(row['row']), int(row['col'])) db = process_db.get(index[1]) if db not in records: raise ValueError(f"Process {index[1]} belongs to {db!r}, which is not among the " f"worker's databases {databases}; list it there to import the " "uncertainty of its exchanges.") records[db].append((index, _record(row))) for db, recs in records.items(): data['If'][db]['declared'], data['If'][db]['undeclared'] = _split(recs) data['Cf'][method] = dict(zip(('declared', 'undeclared'), _split((int(row['row']), _record(row)) for row in cf_params.to_dict('records')))) for spec in _iter_defined(data): _validate(spec) return data
[docs] def _iter_defined(data): for group in data.values(): for block in group.values(): yield from block['declared'].values()
[docs] def _validate(spec: UncertaintySpec, index=None): utype = int(spec['uncertainty_type']) if utype not in SUPPORTED_FAMILIES: raise NotImplementedError( f"Parameter {index if index is not None else ''} declares uncertainty_type={utype}, " "which has no closed-form moments here; supported are 1 (no uncertainty), " "2 (lognormal), 3 (normal), 4 (uniform) and 5 (triangular).") missing = [f for f in SUPPORTED_FAMILIES[utype] if spec.get(f) is None or (isinstance(spec.get(f), float) and math.isnan(spec[f]))] if missing: raise ValueError(f"Parameter {index if index is not None else ''} of uncertainty_type={utype} " f"lacks {missing}.") if utype == stats_arrays.TriangularUncertainty.id and not ( spec['minimum'] <= spec['loc'] <= spec['maximum']): raise ValueError(f"Parameter {index if index is not None else ''}: a triangular distribution " "needs minimum <= loc (the mode) <= maximum.") if utype == stats_arrays.UniformUncertainty.id and not spec['minimum'] <= spec['maximum']: raise ValueError(f"Parameter {index if index is not None else ''}: a uniform distribution " "needs minimum <= maximum.") if utype in (stats_arrays.NormalUncertainty.id, stats_arrays.LognormalUncertainty.id) and spec['scale'] < 0: raise ValueError(f"Parameter {index if index is not None else ''}: scale must be non-negative.")
[docs] def override(uncertainty_data: UncertaintyData, group: str, subgroup: str, specs: Dict[ParamIndex, UncertaintySpec]) -> UncertaintyData: """Declare or replace distributions, e.g. from expert elicitation. Each spec replaces the parameter's distribution; ``amount`` (the value the deterministic model uses) is kept unless the spec gives one. A parameter that is not in the data raises ``KeyError``: it is not a coefficient of the impact (a zero entry of ``B``, or an uncharacterized flow). Args: uncertainty_data: from :func:`import_declared`; modified in place and returned. group: ``'If'`` or ``'Cf'``. subgroup: the database (``'If'``) or the method (``'Cf'``). specs: ``{index: spec}`` with ``index`` = ``(flow row, process column)`` for ``'If'`` and the flow row for ``'Cf'``. """ if group not in uncertainty_data: raise KeyError(f"group must be 'If' (biosphere entries) or 'Cf' (characterization factors), " f"not {group!r}.") if subgroup not in uncertainty_data[group]: kind = 'a database' if group == 'If' else 'the method' raise KeyError(f"{subgroup!r} is not a subgroup of {group!r}; name {kind} of the data: " f"{sorted(uncertainty_data[group])}.") block = uncertainty_data[group][subgroup] replaced = {} for index, spec in specs.items(): current = block['declared'].get(index, block['undeclared'].get(index)) if current is None: raise KeyError(f"{index!r} is not a parameter of {group}/{subgroup}: it is not a nonzero, " "characterized entry of the impact.") new = {field: np.nan for field in _SPEC_FIELDS if field not in ('negative', 'amount')} new['negative'] = bool(current.get('negative', False)) new['amount'] = current['amount'] new.update(spec) new['uncertainty_type'] = int(new['uncertainty_type']) if new['uncertainty_type'] > 0: _validate(new, index) replaced[index] = new # Applied only once every spec has been validated, so a rejected call # leaves the data unchanged. for index, new in replaced.items(): block['declared'].pop(index, None) block['undeclared'].pop(index, None) (block['declared'] if new['uncertainty_type'] > 0 else block['undeclared'])[index] = new return uncertainty_data
[docs] def undeclared(uncertainty_data: UncertaintyData) -> Dict[str, Dict[str, Dict[ParamIndex, UncertaintySpec]]]: """``{group: {subgroup: {index: spec}}}`` of every undeclared parameter.""" return {group: {sub: dict(block['undeclared']) for sub, block in blocks.items()} for group, blocks in uncertainty_data.items()}
[docs] def counts(uncertainty_data: UncertaintyData) -> List[dict]: """One row per group, subgroup and family: how many parameters each holds.""" rows = [] for group, blocks in uncertainty_data.items(): for sub, block in blocks.items(): rows.append({'group': group, 'subgroup': sub, 'status': 'undeclared', 'uncertainty_type': 0, 'n': len(block['undeclared'])}) types = pd.Series([int(s['uncertainty_type']) for s in block['declared'].values()], dtype=int) for utype, n in types.value_counts().sort_index().items(): rows.append({'group': group, 'subgroup': sub, 'status': 'declared', 'uncertainty_type': int(utype), 'n': int(n)}) return rows