Repository: RasaHQ/rasa

Issue:
FallbackPolicy should jump in when the top n intents have similar confidence
Right now it only jumps in if the NLU confidence is below a certain threshold. It should however also jump in if the top 2 intents have a confidence of 0.98 and 0.80 for example.
I've already started looking into this for the rasa-demo

Discussion:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 4df72a587d4..e00dbf75083 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -11,6 +11,7 @@ This project adheres to `Semantic Versioning`_ starting with version 1.0.
 
 Added
 -----
+- `FallbackPolicy` can now be configured to trigger when the difference between confidences of two predicted intents is too narrow
 - throw error during training when triggers are defined in the domain without
   ``MappingPolicy`` being present in the policy ensemble
 
diff --git a/docs/core/policies.rst b/docs/core/policies.rst
index 89d71db64be..bd990037709 100644
--- a/docs/core/policies.rst
+++ b/docs/core/policies.rst
@@ -406,9 +406,11 @@ Fallback Policy
 ^^^^^^^^^^^^^^^
 
 The ``FallbackPolicy`` invokes a :ref:`fallback action
-<fallback-actions>` if the intent recognition
-has a confidence below ``nlu_threshold`` or if none of the dialogue
-policies predict an action with confidence higher than ``core_threshold``.
+<fallback-actions>` if at least one of the following occurs:
+1. The intent recognition has a confidence below ``nlu_threshold``.
+2. The highest ranked intent differs in confidence with the second highest ranked intent
+by less than ``ambiguity_threshold``.
+3. None of the dialogue policies predict an action with confidence higher than ``core_threshold``.
 
 **Configuration:**
 
@@ -420,6 +422,7 @@ policies predict an action with confidence higher than ``core_threshold``.
       policies:
         - name: "FallbackPolicy"
           nlu_threshold: 0.3
+          ambiguity_threshold: 0.1
           core_threshold: 0.3
           fallback_action_name: 'action_default_fallback'
 
@@ -427,6 +430,10 @@ policies predict an action with confidence higher than ``core_threshold``.
     | ``nlu_threshold``          | Min confidence needed to accept an NLU      |
     |                            | prediction                                  |
     +----------------------------+---------------------------------------------+
+    | ``ambiguity_threshold``    | Min amount by which the confidence of the   |
+    |                            | top intent must exceed that of the second   |
+    |                            | highest ranked intent.                      |
+    +----------------------------+---------------------------------------------+
     | ``core_threshold``         | Min confidence needed to accept an action   |
     |                            | prediction from Rasa Core                   |
     +----------------------------+---------------------------------------------+
@@ -446,7 +453,8 @@ policies predict an action with confidence higher than ``core_threshold``.
 
        fallback = FallbackPolicy(fallback_action_name="action_default_fallback",
                                  core_threshold=0.3,
-                                 nlu_threshold=0.3)
+                                 nlu_threshold=0.3,
+                                 ambiguity_threshold=0.1)
 
        agent = Agent("domain.yml", policies=[KerasPolicy(), fallback])
 
@@ -462,7 +470,8 @@ Two-Stage Fallback Policy
 The ``TwoStageFallbackPolicy`` handles low NLU confidence in multiple stages
 by trying to disambiguate the user input.
 
-- If a NLU prediction has a low confidence score, the user is asked to affirm
+- If an NLU prediction has a low confidence score or is not significantly higher
+  than the second highest ranked prediction, the user is asked to affirm
   the classification of the intent.
 
     - If they affirm, the story continues as if the intent was classified
@@ -494,6 +503,7 @@ by trying to disambiguate the user input.
         policies:
           - name: TwoStageFallbackPolicy
             nlu_threshold: 0.3
+            ambiguity_threshold: 0.1
             core_threshold: 0.3
             fallback_core_action_name: "action_default_fallback"
             fallback_nlu_action_name: "action_default_fallback"
@@ -503,6 +513,10 @@ by trying to disambiguate the user input.
     | ``nlu_threshold``             | Min confidence needed to accept an NLU   |
     |                               | prediction                               |
     +-------------------------------+------------------------------------------+
+    | ``ambiguity_threshold``       | Min amount by which the confidence of the|
+    |                               | top intent must exceed that of the second|
+    |                               | highest ranked intent.                   |
+    +--------------------------------------------------------------------------+
     | ``core_threshold``            | Min confidence needed to accept an action|
     |                               | prediction from Rasa Core                |
     +-------------------------------+------------------------------------------+
diff --git a/rasa/core/policies/fallback.py b/rasa/core/policies/fallback.py
index aa96235da5b..e0f1cf036ac 100644
--- a/rasa/core/policies/fallback.py
+++ b/rasa/core/policies/fallback.py
@@ -1,7 +1,7 @@
 import json
 import logging
 import os
-from typing import Any, List, Text
+from typing import Any, List, Text, Optional, Dict, Tuple
 
 from rasa.core.actions.action import ACTION_LISTEN_NAME
 
@@ -30,6 +30,7 @@ class FallbackPolicy(Policy):
         self,
         priority: int = 4,
         nlu_threshold: float = 0.3,
+        ambiguity_threshold: float = 0.1,
         core_threshold: float = 0.3,
         fallback_action_name: Text = "action_default_fallback",
     ) -> None:
@@ -43,11 +44,14 @@ class FallbackPolicy(Policy):
             nlu_threshold: minimum threshold for NLU confidence.
                 If intent prediction confidence is lower than this,
                 predict fallback action with confidence 1.0.
+            ambiguity_threshold: threshold for minimum difference
+                between confidences of the top two predictions
             fallback_action_name: name of the action to execute as a fallback
         """
         super(FallbackPolicy, self).__init__(priority=priority)
 
         self.nlu_threshold = nlu_threshold
+        self.ambiguity_threshold = ambiguity_threshold
         self.core_threshold = core_threshold
         self.fallback_action_name = fallback_action_name
 
@@ -61,20 +65,62 @@ class FallbackPolicy(Policy):
 
         pass
 
+    def nlu_confidence_below_threshold(
+        self, nlu_data: Dict[Text, Any]
+    ) -> Tuple[bool, float]:
+        """Check if the highest confidence is lower than ``nlu_threshold``."""
+
+        # if NLU interpreter does not provide confidence score,
+        # it is set to 1.0 here in order
+        # to not override standard behaviour
+        nlu_confidence = nlu_data.get("intent", {}).get("confidence", 1.0)
+        return (nlu_confidence < self.nlu_threshold, nlu_confidence)
+
+    def nlu_prediction_ambiguous(
+        self, nlu_data: Dict[Text, Any]
+    ) -> Tuple[bool, Optional[float]]:
+        """Check if top 2 confidences are closer than ``ambiguity_threshold``."""
+        intents = nlu_data.get("intent_ranking", [])
+        if len(intents) >= 2:
+            first_confidence = intents[0].get("confidence", 1.0)
+            second_confidence = intents[1].get("confidence", 1.0)
+            difference = first_confidence - second_confidence
+            return (difference < self.ambiguity_threshold, difference)
+        return (False, None)
+
     def should_nlu_fallback(
-        self, nlu_confidence: float, last_action_name: Text
+        self, nlu_data: Dict[Text, Any], last_action_name: Text
     ) -> bool:
-        """Checks if fallback action should be predicted.
+        """Check if fallback action should be predicted.
 
         Checks for:
         - predicted NLU confidence is lower than ``nlu_threshold``
+        - difference in top 2 NLU confidences lower than ``ambiguity_threshold``
         - last action is action listen
         """
+        if last_action_name != ACTION_LISTEN_NAME:
+            return False
 
-        return (
-            nlu_confidence < self.nlu_threshold
-            and last_action_name == ACTION_LISTEN_NAME
-        )
+        below_threshold, nlu_confidence = self.nlu_confidence_below_threshold(nlu_data)
+        ambiguous_prediction, confidence_delta = self.nlu_prediction_ambiguous(nlu_data)
+
+        if below_threshold:
+            logger.debug(
+                "NLU confidence {} is lower "
+                "than NLU threshold {:.2f}. "
+                "".format(nlu_confidence, self.nlu_threshold)
+            )
+            return True
+        elif ambiguous_prediction:
+            logger.debug(
+                "The difference in NLU confidences "
+                "for the top two intents ({}) is lower than "
+                "the ambiguity threshold {:.2f}. "
+                "".format(confidence_delta, self.ambiguity_threshold)
+            )
+            return True
+
+        return False
 
     def fallback_scores(self, domain, fallback_score=1.0):
         """Prediction scores used if a fallback is necessary."""
@@ -95,22 +141,12 @@ class FallbackPolicy(Policy):
 
         nlu_data = tracker.latest_message.parse_data
 
-        # if NLU interpreter does not provide confidence score,
-        # it is set to 1.0 here in order
-        # to not override standard behaviour
-        nlu_confidence = nlu_data.get("intent", {}).get("confidence", 1.0)
-
         if tracker.latest_action_name == self.fallback_action_name:
             result = [0.0] * domain.num_actions
             idx = domain.index_for_action(ACTION_LISTEN_NAME)
             result[idx] = 1.0
 
-        elif self.should_nlu_fallback(nlu_confidence, tracker.latest_action_name):
-            logger.debug(
-                "NLU confidence {} is lower "
-                "than NLU threshold {:.2f}. "
-                "".format(nlu_confidence, self.nlu_threshold)
-            )
+        elif self.should_nlu_fallback(nlu_data, tracker.latest_action_name):
             result = self.fallback_scores(domain)
 
         else:
@@ -135,6 +171,7 @@ class FallbackPolicy(Policy):
         meta = {
             "priority": self.priority,
             "nlu_threshold": self.nlu_threshold,
+            "ambiguity_threshold": self.ambiguity_threshold,
             "core_threshold": self.core_threshold,
             "fallback_action_name": self.fallback_action_name,
         }
diff --git a/rasa/core/policies/two_stage_fallback.py b/rasa/core/policies/two_stage_fallback.py
index b93e51cefb5..7765cedc9f4 100644
--- a/rasa/core/policies/two_stage_fallback.py
+++ b/rasa/core/policies/two_stage_fallback.py
@@ -52,6 +52,7 @@ class TwoStageFallbackPolicy(FallbackPolicy):
         self,
         priority: int = 4,
         nlu_threshold: float = 0.3,
+        ambiguity_threshold: float = 0.1,
         core_threshold: float = 0.3,
         fallback_core_action_name: Text = ACTION_DEFAULT_FALLBACK_NAME,
         fallback_nlu_action_name: Text = ACTION_DEFAULT_FALLBACK_NAME,
@@ -63,6 +64,8 @@ class TwoStageFallbackPolicy(FallbackPolicy):
             nlu_threshold: minimum threshold for NLU confidence.
                 If intent prediction confidence is lower than this,
                 predict fallback action with confidence 1.0.
+            ambiguity_threshold: threshold for minimum difference
+                between confidences of the top two predictions
             core_threshold: if NLU confidence threshold is met,
                 predict fallback action with confidence
                 `core_threshold`. If this is the highest confidence in
@@ -75,7 +78,11 @@ class TwoStageFallbackPolicy(FallbackPolicy):
                  to detect that the user denies the suggested intents.
         """
         super(TwoStageFallbackPolicy, self).__init__(
-            priority, nlu_threshold, core_threshold, fallback_core_action_name
+            priority,
+            nlu_threshold,
+            ambiguity_threshold,
+            core_threshold,
+            fallback_core_action_name,
         )
 
         self.fallback_nlu_action_name = fallback_nlu_action_name
@@ -107,10 +114,9 @@ class TwoStageFallbackPolicy(FallbackPolicy):
         """
 
         nlu_data = tracker.latest_message.parse_data
-        nlu_confidence = nlu_data["intent"].get("confidence", 1.0)
         last_intent_name = nlu_data["intent"].get("name", None)
         should_nlu_fallback = self.should_nlu_fallback(
-            nlu_confidence, tracker.latest_action_name
+            nlu_data, tracker.latest_action_name
         )
         user_rephrased = has_user_rephrased(tracker)
 
@@ -200,6 +206,7 @@ class TwoStageFallbackPolicy(FallbackPolicy):
         meta = {
             "priority": self.priority,
             "nlu_threshold": self.nlu_threshold,
+            "ambiguity_threshold": self.ambiguity_threshold,
             "core_threshold": self.core_threshold,
             "fallback_core_action_name": self.fallback_action_name,
             "fallback_nlu_action_name": self.fallback_nlu_action_name,


Test patch:
diff --git a/tests/core/test_policies.py b/tests/core/test_policies.py
index ffa1e5ba61f..8c1f12cb405 100644
--- a/tests/core/test_policies.py
+++ b/tests/core/test_policies.py
@@ -198,19 +198,33 @@ class TestFallbackPolicy(PolicyTestCollection):
         return p
 
     @pytest.mark.parametrize(
-        "nlu_confidence, last_action_name, should_nlu_fallback",
+        "top_confidence, all_confidences, last_action_name, should_nlu_fallback",
         [
-            (0.1, "some_action", False),
-            (0.1, "action_listen", True),
-            (0.9, "some_action", False),
-            (0.9, "action_listen", False),
+            (0.1, [0.1], "some_action", False),
+            (0.1, [0.1], "action_listen", True),
+            (0.9, [0.9, 0.1], "some_action", False),
+            (0.9, [0.9, 0.1], "action_listen", False),
+            (0.4, [0.4, 0.35], "some_action", False),
+            (0.4, [0.4, 0.35], "action_listen", True),
+            (0.9, [0.9, 0.85], "action_listen", True),
         ],
     )
     def test_should_nlu_fallback(
-        self, trained_policy, nlu_confidence, last_action_name, should_nlu_fallback
+        self,
+        trained_policy,
+        top_confidence,
+        all_confidences,
+        last_action_name,
+        should_nlu_fallback,
     ):
+        nlu_data = {
+            "intent": {"confidence": top_confidence},
+            "intent_ranking": [
+                {"confidence": confidence} for confidence in all_confidences
+            ],
+        }
         assert (
-            trained_policy.should_nlu_fallback(nlu_confidence, last_action_name)
+            trained_policy.should_nlu_fallback(nlu_data, last_action_name)
             is should_nlu_fallback
         )