Repository: gammapy/gammapy

Issue:
SkyEllipse fit convergence problem
@lmohrmann reported that he had problems with the fit convergence of the `SkyEllipse` model we introduced for Gammapy v0.11. I have taken a closer look and could reproduce the problems on simulated data. Here are the likelihood profiles I get:

Position longitude:

<img width="404" alt="Screenshot 2019-04-02 at 23 07 24" src="https://user-images.githubusercontent.com/3715928/55436474-bb786700-559c-11e9-853f-330e8c19b528.png">

Position latitude:

<img width="404" alt="Screenshot 2019-04-02 at 23 07 34" src="https://user-images.githubusercontent.com/3715928/55436446-a13e8900-559c-11e9-8896-25e6eba1259a.png">

Semi major:
<img width="404" alt="Screenshot 2019-04-02 at 23 07 43" src="https://user-images.githubusercontent.com/3715928/55436332-4a38b400-559c-11e9-8998-b89b61d29341.png">

Thinking about it twice, I'm not really surprised by the shape. Given our current implementation of the `SkyEllipse` and `SkyDisk`, coordinate pixels will move in and out of the condition defining the area of the ellipse / disk, depending on the center position of the model. This leads to the jumps in the likelihood surface. The issue can technically be resolved by integrating over the area of the pixel, but I wonder how this has been handled in the past without the integration? Maybe softening the edge, e.g. by introducing some slope?

I've never fitted one of these models with "sharp" edges in sherpa nor HAP or M++. Do you know anything about this @registerrier or @luca-giunti?


Discussion:
Note that if this comes from a simulation made with the same model, the data display the same behavior. We can assume that this will increase even more the edgy likelihood profile. I am a bit surprised by the small sizes of the steps. What is the bin size used here?

I am not sure I have a better alternative to propose except integration, I must say. 
Thanks, @registerrier for the comment. The bin size used for the plots I showed above was 0.05 deg. Here are the same profiles for 0.01 deg for comparison (I did not subtract the fit minimum this time, but for the shape it doesn't matter... ):

Position longitude:

![Screenshot 2019-04-03 at 09 57 57](https://user-images.githubusercontent.com/3715928/55462480-25255f00-55f7-11e9-94fb-e0c0a52af15a.png)

Position latitude:
![Screenshot 2019-04-03 at 09 58 14](https://user-images.githubusercontent.com/3715928/55462466-1ccd2400-55f7-11e9-9796-7aea707b2b3a.png)

Semi major:

![Screenshot 2019-04-03 at 09 58 24](https://user-images.githubusercontent.com/3715928/55462497-2f475d80-55f7-11e9-8393-a0878fda6503.png)

I see a few possibilities to resolve this problem:

- Introduce the `SkyModel.oversample` attribute and set a default of maybe `4` for
the `SkyDisk` and `SkyEllipse` models. This was foreseen in the Dataset PIG anyway and is probably a practical solution. It still might no completely resolve the issue, as it remains just on a different spatial scale. I guess it depends on the step size the optimiser uses...which is not necessarily under our control. 
 
- Maybe use https://github.com/astropy/regions/blob/master/regions/_geometry/elliptical_overlap.pyx to evaluate the `SkyEllipse` and https://github.com/astropy/regions/blob/master/regions/_geometry/circular_overlap.pyx to evaluate the `SkyDisk`?

- Smooth the edge of the `SkyDisk` and `SkyEllipse` somehow...

- Implement something similar as I did for the `SkyPointSource`, based on a local gradients...

- Tell users, that the `SkyDisk` and `SkyEllipse` models are not suitable for fitting positions. And positional parameters should be frozen..?

A different approach here would be to tell the optimiser the minimal step size for `lon_0` and `lat_0` and set it to ~0.5 pixels. I think this should work for `Minuit`, but might not work for other optimisers, that don't accept the step size as a parameter. Side comment: I think by scaling the `lon_0` and `lat_0` for the fitting we actually worsened the problem, so probably we should not do this...



I think we can probably solve part of the issues if `MapEvaluator` computes model and exposure evaluations at bin edges and then apply trapezoidal integration rather than the value at bin center times the bon volume.

If this does not work, then we have to indeed find a way to perform integration, e.g. with astropy models. But then we are back to the question of planar vs spherical models again.

And regarding your side comment, I agree that we should not scale `lon_0` and `lat_0`, and probably all angles no?  

Resolving patch:
diff --git a/gammapy/image/models/core.py b/gammapy/image/models/core.py
--- a/gammapy/image/models/core.py
+++ b/gammapy/image/models/core.py
@@ -8,6 +8,7 @@
 from ...maps import Map
 from scipy.integrate import quad
 
+
 __all__ = [
     "SkySpatialModel",
     "SkyPointSource",
@@ -23,6 +24,11 @@
 log = logging.getLogger(__name__)
 
 
+def smooth_edge(x, width):
+    value = (x / width).to_value("")
+    return 0.5 - np.clip(value, -0.5, 0.5)
+
+
 class SkySpatialModel(Model):
     """Sky spatial model base class."""
 
@@ -187,7 +193,7 @@ class SkyDisk(SkySpatialModel):
                     0 & \text{for } \theta > r_0
                 \end{cases}
 
-    where :math:`\theta` is the sky separation
+    where :math:`\theta` is the sky separation.
 
     Parameters
     ----------
@@ -197,21 +203,24 @@ class SkyDisk(SkySpatialModel):
         :math:`lat_0`
     r_0 : `~astropy.coordinates.Angle`
         :math:`r_0`
+    edge : `~astropy.coordinates.Angle`
+        Width of the edge.
     frame : {"galactic", "icrs"}
         Coordinate frame of `lon_0` and `lat_0`.
     """
 
     __slots__ = ["frame", "lon_0", "lat_0", "r_0"]
 
-    def __init__(self, lon_0, lat_0, r_0, frame="galactic"):
+    def __init__(self, lon_0, lat_0, r_0, edge="0.01 deg", frame="galactic"):
         self.frame = frame
         self.lon_0 = Parameter(
             "lon_0", Longitude(lon_0).wrap_at("180d"), min=-180, max=180
         )
         self.lat_0 = Parameter("lat_0", Latitude(lat_0), min=-90, max=90)
         self.r_0 = Parameter("r_0", Angle(r_0))
+        self.edge = Parameter("edge", Angle(edge), min=0.01, frozen=True)
 
-        super().__init__([self.lon_0, self.lat_0, self.r_0])
+        super().__init__([self.lon_0, self.lat_0, self.r_0, self.edge])
 
     @property
     def evaluation_radius(self):
@@ -226,13 +235,15 @@ def evaluation_radius(self):
         return self.parameters["r_0"].quantity
 
     @staticmethod
-    def evaluate(lon, lat, lon_0, lat_0, r_0):
+    def evaluate(lon, lat, lon_0, lat_0, r_0, edge):
         """Evaluate the model (static function)."""
         sep = angular_separation(lon, lat, lon_0, lat_0)
 
         # Surface area of a spherical cap, see https://en.wikipedia.org/wiki/Spherical_cap
         norm = 1.0 / (2 * np.pi * (1 - np.cos(r_0)))
-        return u.Quantity(norm.value * (sep <= r_0), "sr-1", copy=False)
+
+        in_disk = smooth_edge(sep - r_0, edge)
+        return u.Quantity(norm.value * in_disk, "sr-1", copy=False)
 
 
 class SkyEllipse(SkySpatialModel):
@@ -270,6 +281,8 @@ class SkyEllipse(SkySpatialModel):
         :math:`\theta`:
         Rotation angle of the major semiaxis.  The rotation angle increases clockwise
         (i.e., East of North) from the positive `lon` axis.
+    edge : `~astropy.coordinates.Angle`
+        Width of the edge.
     frame : {"galactic", "icrs"}
         Coordinate frame of `lon_0` and `lat_0`.
 
@@ -306,7 +319,7 @@ class SkyEllipse(SkySpatialModel):
 
     __slots__ = ["frame", "lon_0", "lat_0", "semi_major", "e", "theta", "_offset_by"]
 
-    def __init__(self, lon_0, lat_0, semi_major, e, theta, frame="galactic"):
+    def __init__(self, lon_0, lat_0, semi_major, e, theta, edge="0.01 deg", frame="galactic"):
         try:
             from astropy.coordinates.angle_utilities import offset_by
 
@@ -322,8 +335,9 @@ def __init__(self, lon_0, lat_0, semi_major, e, theta, frame="galactic"):
         self.semi_major = Parameter("semi_major", Angle(semi_major))
         self.e = Parameter("e", e, min=0, max=1)
         self.theta = Parameter("theta", Angle(theta))
+        self.edge = Parameter("edge", Angle(edge), frozen=True, min=0.01)
 
-        super().__init__([self.lon_0, self.lat_0, self.semi_major, self.e, self.theta])
+        super().__init__([self.lon_0, self.lat_0, self.semi_major, self.e, self.theta, self.edge])
 
     @property
     def evaluation_radius(self):
@@ -356,7 +370,7 @@ def integral_fcn(x, a, b):
             2 * quad(lambda x: integral_fcn(x, semi_major, semi_minor), 0, np.pi)[0]
         ) ** -1
 
-    def evaluate(self, lon, lat, lon_0, lat_0, semi_major, e, theta):
+    def evaluate(self, lon, lat, lon_0, lat_0, semi_major, e, theta, edge):
         """Evaluate the model (static function)."""
         # find the foci of the ellipse
         c = semi_major * e
@@ -365,7 +379,8 @@ def evaluate(self, lon, lat, lon_0, lat_0, semi_major, e, theta):
 
         sep_1 = angular_separation(lon, lat, lon_1, lat_1)
         sep_2 = angular_separation(lon, lat, lon_2, lat_2)
-        in_ellipse = sep_1 + sep_2 <= 2 * semi_major
+
+        in_ellipse = smooth_edge(sep_1 + sep_2 - 2 * semi_major, edge)
 
         norm = SkyEllipse.compute_norm(semi_major, e)
         return u.Quantity(norm * in_ellipse, "sr-1", copy=False)


Test patch:
diff --git a/gammapy/image/models/tests/test_core.py b/gammapy/image/models/tests/test_core.py
--- a/gammapy/image/models/tests/test_core.py
+++ b/gammapy/image/models/tests/test_core.py
@@ -58,6 +58,13 @@ def test_sky_disk():
     assert radius.unit == "deg"
     assert_allclose(radius.value, r_0.value)
 
+def test_sky_disk_edge():
+    r_0 = 2 * u.deg
+    model = SkyDisk(lon_0="0 deg", lat_0="0 deg", r_0=r_0)
+    value_center = model(0 * u.deg, 0 * u.deg)
+    value_edge = model(r_0, 0 * u.deg)
+    assert_allclose((value_edge / value_center).to_value(""), 0.5)
+
 
 def test_sky_ellipse():
     pytest.importorskip("astropy", minversion="3.1.1")
@@ -108,6 +115,15 @@ def test_sky_ellipse():
     )
 
 
+def test_sky_ellipse_edge():
+    pytest.importorskip("astropy", minversion="3.1.1")
+    r_0 = 2 * u.deg
+    model = SkyEllipse(lon_0="0 deg", lat_0="0 deg", semi_major=r_0, e=0.5, theta="0 deg")
+    value_center = model(0 * u.deg, 0 * u.deg)
+    value_edge = model(r_0, 0 * u.deg)
+    assert_allclose((value_edge / value_center).to_value(""), 0.5)
+
+
 def test_sky_shell():
     width = 2 * u.deg
     rad = 2 * u.deg