# Carneades Argument Evaluation Structure
#
# Copyright (C) 2014 Ewan Klein
# Author: Ewan Klein <ewan@inf.ed.ac.uk>
# Based on: https://hackage.haskell.org/package/CarneadesDSL
#
# For license information, see LICENSE
"""
==========
Overview
==========
Propositions
============
First, let's create some propositions using the :class:`PropLiteral`
constructor. All propositions are atomic, that is, either positive or
negative literals.
>>> kill = PropLiteral('kill')
>>> kill.polarity
True
>>> intent = PropLiteral('intent')
>>> murder = PropLiteral('murder')
>>> witness1 = PropLiteral('witness1')
>>> unreliable1 = PropLiteral('unreliable1')
>>> witness2 = PropLiteral('witness2')
>>> unreliable2 = PropLiteral('unreliable2')
The :meth:`negate` method allows us to introduce negated propositions.
>>> neg_intent = intent.negate()
>>> print(neg_intent)
-intent
>>> neg_intent.polarity
False
>>> neg_intent == intent
False
>>> neg_intent.negate() == intent
True
Arguments
=========
Arguments are built with the :class:`Argument` constructor. They are required
to have a conclusion, and may also have premises and exceptions.
>>> arg1 = Argument(murder, premises={kill, intent})
>>> arg2 = Argument(intent, premises={witness1}, exceptions={unreliable1})
>>> arg3 = Argument(neg_intent, premises={witness2}, exceptions={unreliable2})
>>> print(arg1)
[intent, kill], ~[] => murder
In order to organise the dependencies between the conclusion of an argument
and its premises and exceptions, we model them using a directed graph called
an :class:`ArgumentSet`. Notice that the premise of one argument (e.g., the
``intent`` premise of ``arg1``) can be the conclusion of another argument (i.e.,
``arg2``)).
>>> argset = ArgumentSet()
>>> argset.add_argument(arg1, arg_id='arg1')
>>> argset.add_argument(arg2, arg_id='arg2')
>>> argset.add_argument(arg3, arg_id='arg3')
There is a :func:`draw` method which allows us to view the resulting graph.
>>> argset.draw() # doctest: +SKIP
Proof Standards
===============
In evaluating the relative value of arguments for a particular conclusion
``p``, we need to determine what standard of *proof* is required to establish
``p``. The notion of proof used here is not formal proof in a logical
system. Instead, it tries to capture how substantial the arguments are
in favour of, or against, a particular conclusion.
The :class:`ProofStandard` constructor is initialised with a list of
``(proposition, name-of-proof-standard)`` pairs. The default proof standard,
viz., ``'scintilla'``, is the weakest level. Different
propositions can be assigned different proof standards that they need
to attain.
>>> ps = ProofStandard([(intent, "beyond_reasonable_doubt")],
... default='scintilla')
Carneades Argument Evaluation Structure
=======================================
The core of the argumentation model is a data structure plus set of
rules for evaluating arguments; this is called a Carneades Argument
Evaluation Structure (CAES). A CAES consists of a set of arguments,
an audience (or jury), and a method for determining whether propositions
satisfy the relevant proof standards.
The role of the audience is modeled as an :class:`Audience`, consisting
of a set of assumed propositions, and an assignment of weights to
arguments.
>>> assumptions = {kill, witness1, witness2, unreliable2}
>>> weights = {'arg1': 0.8, 'arg2': 0.3, 'arg3': 0.8}
>>> audience = Audience(assumptions, weights)
Once an audience has been defined, we can use it to initialise a
:class:`CAES`, together with instances of :class:`ArgumentSet` and
:class:`ProofStandard`:
>>> caes = CAES(argset, audience, ps)
>>> caes.get_all_arguments()
[intent, kill], ~[] => murder
[witness1], ~[unreliable1] => intent
[witness2], ~[unreliable2] => -intent
The :meth:`get_arguments` method returns the list of arguments in an
:class:`ArgumentSet` which support a given proposition.
A proposition is said to be *acceptable* in a CAES if it meets its required
proof standard. The process of checking whether a proposition meets its proof
standard requires another notion: namely, whether the arguments that support
it are *applicable*. An argument ``arg`` is applicable if and only if all its
premises either belong to the audience's assumptions or are acceptable;
moreover, the exceptions of ``arg`` must not belong to the assumptions or be
acceptable. For example, `arg2`, which supports the conclusion `intent`, is
acceptable since `witness1` is an assumption, while the exception
`unreliable1` is neither an assumption nor acceptable.
>>> arg_for_intent = argset.get_arguments(intent)[0]
>>> print(arg_for_intent)
[witness1], ~[unreliable1] => intent
>>> caes.applicable(arg_for_intent)
True
>>> caes.acceptable(intent)
False
Although there is an argument (``arg3``) for `-intent`, it is not applicable,
since the exception `unreliable2` does belong to the audience's assumptions.
>>> any(caes.applicable(arg) for arg in argset.get_arguments(neg_intent))
False
This in turn has the consequence that `-intent` is not acceptable.
>>> caes.acceptable(neg_intent)
False
Despite the fact that the argument `arg2` for `murder` is applicable,
the conclusion `murder` is not acceptable, since
>>> caes.acceptable(murder)
False
>>> caes.acceptable(murder.negate())
False
"""
from collections import namedtuple, defaultdict
import logging
import os
import sys
from igraph import Graph, plot
# fix to ensure that package is loaded properly on system path
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
from carneades.tracecalls import TraceCalls
LOGLEVEL = logging.DEBUG
# Uncomment the following line to raise the logging level and thereby turn off
# debug messages
# LOGLEVEL = logging.INFO
logging.basicConfig(format='%(levelname)s: %(message)s', level=LOGLEVEL)
[docs]class PropLiteral(object):
"""
Proposition literals have most of the properties of ordinary strings,
except that the negation method is Boolean; i.e.
>>> a = PropLiteral('a')
>>> a.negate().negate() == a
True
"""
[docs] def __init__(self, string, polarity=True):
"""
Propositions are either positive or negative atoms.
"""
self.polarity = polarity
self._string = string
[docs] def negate(self):
"""
Negation of a proposition.
We create a copy of the current proposition and flip its polarity.
"""
polarity = (not self.polarity)
return PropLiteral(self._string, polarity=polarity)
def __str__(self):
"""
Override ``__str__()`` so that negation is realised as a prefix on the
string.
"""
if self.polarity:
return self._string
return "-" + self._string
def __hash__(self):
return self._string.__hash__()
def __repr__(self):
return self.__str__()
def __eq__(self, other):
if isinstance(other, self.__class__):
return self.__str__() == other.__str__()
else:
return False
def __ne__(self, other):
return not self.__eq__(other)
def __lt__(self, other):
return self.__str__() < other.__str__()
[docs]class Argument(object):
"""
An argument consists of a conclusion, a set of premises and a set of
exceptions (both of which can be empty).
Although arguments should have identifiers (`arg_id`), it is preferable
to specify these when calling the :meth:`add_argument` method of
:class:`ArgumentSet`.
"""
[docs] def __init__(self, conclusion, premises=set(), exceptions=set()):
"""
:param conclusion: The conclusion of the argument.
:type conclusion: :class:`PropLiteral`
:param premises: The premises of the argument.
:type premises: set(:class:`PropLiteral`)
:param exceptions: The exceptions of the argument
:type exceptions: set(:class:`PropLiteral`)
"""
self.conclusion = conclusion
self.premises = premises
self.exceptions = exceptions
self.arg_id = None
def __str__(self):
"""
Define print string for arguments.
We follow similar conventions to those used by the CarneadesDSL
Haskell implementation.
Premises and exceptions are sorted to facilitate doctest comparison.
"""
if len(self.premises) == 0:
prems = "[]"
else:
prems = sorted(self.premises)
if len(self.exceptions) == 0:
excepts = "[]"
else:
excepts = sorted(self.exceptions)
return "{}, ~{} => {}".format(prems, excepts, self.conclusion)
[docs]class ArgumentSet(object):
"""
An ``ArgumentSet`` is modeled as a dependency graph where vertices represent
the components of an argument. A vertex corresponding to the conclusion
of an argument *A* will **depend on** the premises and exceptions in *A*.
The graph is built using the `igraph <http://igraph.org/>`_ library. This
allows *attributes* to be associated with both vertices and edges.
Attributes are represented as Python dictionaries where the key (which
must be a string) is the name of the attribute and the value is the
attribute itself. For more details, see the
`igraph tutorial\
<http://igraph.org/python/doc/tutorial/tutorial.html#setting-and-retrieving-attributes>`_.
"""
[docs] def __init__(self):
self.graph = Graph()
self.graph.to_directed()
self.arg_count = 1
self.arguments = []
[docs] def propset(self):
"""
The set of :class:`PropLiteral`\ s represented by the vertices in
the graph.
Retrieving this set relies on the fact that :meth:`add_proposition`
sets a value for the ``prop`` attribute in vertices created when a
new proposition is added to the graph.
"""
g = self.graph
props = set()
try:
props = {p for p in g.vs['prop']}
except KeyError:
pass
return props
[docs] def add_proposition(self, proposition):
"""
Add a proposition to a graph if it is not already present as a vertex.
:param proposition: The proposition to be added to the graph.
:type proposition: :class:`PropLiteral`
:return: The graph vertex corresponding to the proposition.
:rtype: :class:`Graph.Vertex`
:raises TypeError: if the input is not a :class:`PropLiteral`.
"""
if isinstance(proposition, PropLiteral):
if proposition in self.propset():
logging.debug("Proposition '{}' is already in graph".\
format(proposition))
else:
# add the proposition as a vertex attribute, recovered via the
# key 'prop'
self.graph.add_vertex(prop=proposition)
logging.debug("Added proposition '{}' to graph".\
format(proposition))
return self.graph.vs.select(prop=proposition)[0]
else:
raise TypeError('Input {} should be PropLiteral'.\
format(proposition))
[docs] def add_argument(self, argument, arg_id=None):
"""
Add an argument to the graph.
:parameter argument: The argument to be added to the graph.
:type argument: :class:`Argument`
:parameter arg_id: The ID of the argument
:type arg_id: str or None
"""
g = self.graph
if arg_id is not None:
argument.arg_id = arg_id
else:
argument.arg_id = 'arg{}'.format(self.arg_count)
self.arg_count += 1
self.arguments.append(argument)
# add the arg_id as a vertex attribute, recovered via the 'arg' key
self.graph.add_vertex(arg=argument.arg_id)
arg_v = g.vs.select(arg=argument.arg_id)[0]
# add proposition vertices to the graph
conclusion_v = self.add_proposition(argument.conclusion)
self.add_proposition(argument.conclusion.negate())
premise_vs =\
[self.add_proposition(prop) for prop in sorted(argument.premises)]
exception_vs =\
[self.add_proposition(prop) for prop in sorted(argument.exceptions)]
target_vs = premise_vs + exception_vs
# add new edges to the graph
edge_to_arg = [(conclusion_v.index, arg_v.index)]
edges_from_arg = [(arg_v.index, target.index) for target in target_vs]
g.add_edges(edge_to_arg + edges_from_arg)
[docs] def get_arguments(self, proposition):
"""
Find the arguments for a proposition in an *ArgumentSet*.
:param proposition: The proposition to be checked.
:type proposition: :class:`PropLiteral`
:return: A list of the arguments pro the proposition
:rtype: list(:class:`Argument`)
:raises ValueError: if the input :class:`PropLiteral` isn't present\
in the graph.
"""
g = self.graph
# index of vertex associated with the proposition
vs = g.vs.select(prop=proposition)
try:
conc_v_index = vs[0].index
# IDs of vertices reachable in one hop from the proposition's vertex
target_IDs = [e.target for e in g.es.select(_source=conc_v_index)]
# the vertices indexed by target_IDs
out_vs = [g.vs[i] for i in target_IDs]
arg_IDs = [v['arg'] for v in out_vs]
args = [arg for arg in self.arguments if arg.arg_id in arg_IDs]
return args
except IndexError:
raise ValueError("Proposition '{}' is not in the current graph".\
format(proposition))
[docs] def draw(self, debug=False):
"""
Visualise an :class:`ArgumentSet` as a labeled graph.
:parameter debug: If :class:`True`, add the vertex index to the label.
"""
g = self.graph
# labels for nodes that are classed as propositions
labels = g.vs['prop']
# insert the labels for nodes that are classed as arguments
for i in range(len(labels)):
if g.vs['arg'][i] is not None:
labels[i] = g.vs['arg'][i]
if debug:
d_labels = []
for (i, label) in enumerate(labels):
d_labels.append("{}\nv{}".format(label, g.vs[i].index))
labels = d_labels
g.vs['label'] = labels
roots = [i for i in range(len(g.vs)) if g.indegree()[i] == 0]
ALL = 3 # from igraph
layout = g.layout_reingold_tilford(mode=ALL, root=roots)
plot_style = {}
plot_style['vertex_color'] = \
['lightblue' if x is None else 'pink' for x in g.vs['arg']]
plot_style['vertex_size'] = 60
plot_style['vertex_shape'] = \
['circle' if x is None else 'rect' for x in g.vs['arg']]
plot_style['margin'] = 40
plot_style['layout'] = layout
plot(g, **plot_style)
[docs] def write_to_graphviz(self, fname=None):
"""
Write the graph to a file using the `Graphviz <http://www.graphviz.org>`_ dot language.
A dot file can be converted to multiple `output formats
<http://www.graphviz.org/content/output-formats>`_, including ``png``
and ``pdf``. For example, ``dot -Tpng graph.dot > graph.png`` will produce an
output file ``graph.png``.
:parameter fname: Name of file to write the results to. If no filename is supplied,\
the results will be written to ``graph.dot``.
"""
g = self.graph
result = "digraph G{ \n"
for vertex in g.vs:
arg_label = vertex.attributes()['arg']
prop_label = vertex.attributes()['prop']
if arg_label:
dot_str = (arg_label +
' [color="black", fillcolor="pink", width=.75, '
'shape=box, style="filled"]; \n')
elif prop_label:
dot_str = ('"{}"'.format(prop_label) +
' [color="black", fillcolor="lightblue", '
'fixedsize=true, width=1 shape="circle", '
'style="filled"]; \n')
result += dot_str
for edge in g.es:
source_label = g.vs[edge.source]['prop'] if\
g.vs[edge.source]['prop'] else g.vs[edge.source]['arg']
target_label = g.vs[edge.target]['prop'] if\
g.vs[edge.target]['prop'] else g.vs[edge.target]['arg']
result += '"{}" -> "{}"; \n'.format(source_label, target_label)
result += "}"
if fname is None:
fname = 'graph.dot'
with open(fname, 'w') as f:
print(result, file=f)
[docs]class ProofStandard(object):
"""
Each proposition in a CAES is associated with a proof standard.
A proof standard is initialised by supplying a (possibly empty) list of
pairs, each consisting of a proposition and the name of a proof standard.
>>> intent = PropLiteral('intent')
>>> ps = ProofStandard([(intent, "beyond_reasonable_doubt")])
Possible values for proof standards: `"scintilla"`, `"preponderance"`,
`"clear_and_convincing"`, `"beyond_reasonable_doubt"`, and
`"dialectical_validity"`.
"""
[docs] def __init__(self, propstandards, default='scintilla'):
"""
:param propstandards: the proof standard associated with\
each proposition under consideration.
:type propstandards: list(tuple(:class:`PropLiteral`, str))
"""
self.proof_standards = ["scintilla", "preponderance",
"clear_and_convincing",
"beyond_reasonable_doubt",
"dialectical_validity"]
self.default = default
self.config = defaultdict(lambda: self.default)
self._set_standard(propstandards)
def _set_standard(self, propstandards):
for (prop, standard) in propstandards:
if standard not in self.proof_standards:
raise ValueError("{} is not a valid proof standard".\
format(standard))
self.config[prop] = standard
[docs] def get_proofstandard(self, proposition):
"""
Determine the proof standard associated with a proposition.
:param proposition: The proposition to be checked.
:type proposition: :class:`PropLiteral`
"""
return self.config[proposition]
Audience = namedtuple('Audience', ['assumptions', 'weight'])
"""
An audience has assumptions about which premises hold and also
assigns weights to arguments.
:param assumptions: The assumptions held by the audience
:type assumptions: set(:class:`PropLiteral`)
:param weights: An mapping from :class:`Argument`\ s to weights.
:type weights: dict
"""
[docs]class CAES(object):
"""
A class that represents a Carneades Argument Evaluation Structure (CAES).
"""
[docs] def __init__(self, argset, audience, proofstandard, alpha=0.4, beta=0.3,
gamma=0.2):
"""
:parameter argset: the argument set used in the CAES
:type argset: :class:`ArgSet`
:parameter audience: the audience for the CAES
:type audience: :class:`Audience`
:parameter proofstandard: the proof standards used in the CAES
:type proofstandard: :class:`ProofStandard`
:parameter alpha: threshold of strength of argument required for a\
proposition to reach the proof standards "clear and convincing" and\
"beyond reasonable doubt".
:type alpha: float in interval [0, 1]
:parameter beta: difference required between strength of\
argument *pro* a proposition vs strength of argument *con*\
to reach the proof standard "clear and convincing".
:type beta: float in interval [0, 1]
:parameter gamma: threshold of strength of a *con* argument required\
for a proposition to reach the proof standard "beyond reasonable\
doubt".
:type gamma: float in interval [0, 1]
"""
self.argset = argset
self.assumptions = audience.assumptions
self.weight = audience.weight
self.standard = proofstandard
self.alpha = alpha
self.beta = beta
self.gamma = gamma
[docs] def get_all_arguments(self):
"""
Show all arguments in the :class:`ArgSet` of the CAES.
"""
for arg in self.argset.arguments:
print(arg)
@TraceCalls()
[docs] def applicable(self, argument):
"""
An argument is *applicable* in a CAES if it needs to be taken into
account when evaluating the CAES.
:parameter argument: The argument whose applicablility is being\
determined.
:type argument: :class:`Argument`
:rtype: bool
"""
_acceptable = lambda p: self.acceptable(p)
return self._applicable(argument, _acceptable)
def _applicable(self, argument, _acceptable):
"""
:parameter argument: The argument whose applicablility is being
determined.
:type argument: :class:`Argument`
:parameter _acceptable: The function which determines the
acceptability of a proposition in the CAES.
:type _acceptable: LambdaType
:rtype: bool
"""
logging.debug('Checking applicability of {}...'.format(argument.arg_id))
logging.debug('Current assumptions: {}'.format(self.assumptions))
logging.debug('Current premises: {}'.format(argument.premises))
b1 = all(p in self.assumptions or \
(p.negate() not in self.assumptions and \
_acceptable(p)) for p in argument.premises)
if argument.exceptions:
logging.debug('Current exception: {}'.format(argument.exceptions))
b2 = all(e not in self.assumptions and \
(e.negate() in self.assumptions or \
not _acceptable(e)) for e in argument.exceptions)
return b1 and b2
@TraceCalls()
[docs] def acceptable(self, proposition):
"""
A conclusion is *acceptable* in a CAES if it can be arrived at under
the relevant proof standards, given the beliefs of the audience.
:param proposition: The conclusion whose acceptability is to be\
determined.
:type proposition: :class:`PropLiteral`
:rtype: bool
"""
standard = self.standard.get_proofstandard(proposition)
logging.debug("Checking whether proposition '{}'"
"meets proof standard '{}'.".\
format(proposition, standard))
return self.meets_proof_standard(proposition, standard)
@TraceCalls()
[docs] def meets_proof_standard(self, proposition, standard):
"""
Determine whether a proposition meets a given proof standard.
:param proposition: The proposition which should meet the relevant\
proof standard.
:type proposition: :class:`PropLiteral`
:parameter standard: a specific level of proof;\
see :class:`ProofStandard` for admissible values
:type standard: str
:rtype: bool
"""
arguments = self.argset.get_arguments(proposition)
result = False
if standard == 'scintilla':
result = any(arg for arg in arguments if self.applicable(arg))
elif standard == 'preponderance':
result = self.max_weight_pro(proposition) > \
self.max_weight_con(proposition)
elif standard == 'clear_and_convincing':
mwp = self.max_weight_pro(proposition)
mwc = self.max_weight_con(proposition)
exceeds_alpha = mwp > self.alpha
diff_exceeds_gamma = (mwp - mwc) > self.gamma
logging.debug("max weight pro '{}' is {}".format(proposition, mwp))
logging.debug("max weight con '{}' is {}".format(proposition, mwc))
logging.debug("max weight pro '{}' > alpha '{}': {}".\
format(mwp, self.alpha, exceeds_alpha))
logging.debug("diff between pro and con = {} > gamma: {}".\
format(mwp-mwc, diff_exceeds_gamma))
result = (mwp > self.alpha) and (mwp - mwc > self.gamma)
elif standard == 'beyond_reasonable_doubt':
result = self.meets_proof_standard(proposition,
'clear_and_convincing') \
and \
self.max_weight_con(proposition) < self.gamma
return result
[docs] def weight_of(self, argument):
"""
Retrieve the weight associated by the CAES audience with an argument.
:parameter argument: The argument whose weight is being determined.
:type argument: :class:`Argument`
:return: The weight of the argument.
:rtype: float in interval [0, 1]
"""
arg_id = argument.arg_id
try:
return self.weight[arg_id]
except KeyError:
raise ValueError("No weight assigned to argument '{}'.".\
format(arg_id))
[docs] def max_weight_applicable(self, arguments):
"""
Retrieve the weight of the strongest applicable argument in a list
of arguments.
:parameter arguments: The arguments whose weight is being compared.
:type arguments: list(:class:`Argument`)
:return: The maximum of the weights of the arguments.
:rtype: float in interval [0, 1]
"""
arg_ids = [arg.arg_id for arg in arguments]
applicable_args = [arg for arg in arguments if self.applicable(arg)]
if len(applicable_args) == 0:
logging.debug('No applicable arguments in {}'.format(arg_ids))
return 0.0
applic_arg_ids = [arg.arg_id for arg in applicable_args]
logging.debug('Checking applicability and weights of {}'.\
format(applic_arg_ids))
weights = [self.weight_of(argument) for argument in applicable_args]
logging.debug('Weights of {} are {}'.format(applic_arg_ids, weights))
return max(weights)
[docs] def max_weight_pro(self, proposition):
"""
The maximum of the weights pro the proposition.
:param proposition: The conclusion whose acceptability is to be\
determined.
:type proposition: :class:`PropLiteral`
:rtype: float in interval [0, 1]
"""
args = self.argset.get_arguments(proposition)
return self.max_weight_applicable(args)
[docs] def max_weight_con(self, proposition):
"""
The maximum of the weights con the proposition.
:param proposition: The conclusion whose acceptability is to be\
determined.
:type proposition: :class:`PropLiteral`
:rtype: float in interval [0, 1]
"""
con = proposition.negate()
args = self.argset.get_arguments(con)
return self.max_weight_applicable(args)
[docs]def arg_demo():
"""
Demo of how to initialise and call methods of a CAES.
"""
kill = PropLiteral('kill')
intent = PropLiteral('intent')
neg_intent = intent.negate()
murder = PropLiteral('murder')
witness1 = PropLiteral('witness1')
unreliable1 = PropLiteral('unreliable1')
witness2 = PropLiteral('witness2')
unreliable2 = PropLiteral('unreliable2')
ps = ProofStandard([(intent, "beyond_reasonable_doubt")])
arg1 = Argument(murder, premises={kill, intent})
arg2 = Argument(intent, premises={witness1}, exceptions={unreliable1})
arg3 = Argument(neg_intent, premises={witness2}, exceptions={unreliable2})
argset = ArgumentSet()
argset.add_argument(arg1)
argset.add_argument(arg2)
argset.add_argument(arg3)
argset.draw()
argset.write_to_graphviz()
assumptions = {kill, witness1, witness2, unreliable2}
weights = {'arg1': 0.8, 'arg2': 0.3, 'arg3': 0.8}
audience = Audience(assumptions, weights)
caes = CAES(argset, audience, ps)
caes.acceptable(murder)
caes.acceptable(murder.negate())
DOCTEST = False
if __name__ == '__main__':
if DOCTEST:
import doctest
doctest.testmod(optionflags=doctest.NORMALIZE_WHITESPACE)
else:
arg_demo()