Repository: google/meridian

Issue:
run meridian using monthly data
Hi,
 
I wanted to run Meridian using monthly data, but due to the interval days check, it requires equal intervals between observations, which is inconvenient for monthly data. Is there a way to modify this requirement for monthly data so that we can, for example, prepare data for the first day of each month? I believe this error might occur quite often, 
so it might be beneficial to work on a general solution.

Please see below **interval_days()** method in https://github.com/google/meridian/tree/main/meridian/data/time_coordinates.py

@functools.cached_property
  def interval_days(self) -> int:
    """Returns the interval between two neighboring dates in `all_dates`.

    Raises:
      ValueError if the date index is not regularly spaced.
    """
    # Calculate the difference between consecutive dates, in days.
    diff = self.datetime_index.to_series().diff().dt.days.dropna()

    if diff.nunique() == 0:
      # This edge case happens when there is only one date in the index.
      # This is unlikely to happen in practice, but we handle it just in case.
      warnings.warn(
          "The time coordinates only have one date. Returning an interval of 0."
      )
      return 0

Please see below the error I encountered when I tried to run Meridian using monthly data with observations on the first day of each month.

![Image](https://github.com/user-attachments/assets/e3db4fe6-19db-4869-b701-77614bef95cc)


Best Regards,
Marlena

Discussion:
@cpulavarthi: Hello Marlena,

Thank you for highlighting the issues you are facing with the interval days check. We recommend using equally spaced 30 day intervals for monthly data.

We will take your feedback into consideration and check if it is feasible to modify and implement this in Meridian. You can track the previous changes to Meridian and unreleased feature updates in our [Change Log](https://github.com/google/meridian/blob/main/CHANGELOG.md).

Feel free to reach out for any further queries or suggestions regarding Meridian!

Google Meridian Support Team

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 38415b5..278d878 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,8 @@ To release a new version (e.g. from `1.0.0` -> `2.0.0`):
 
 ## [Unreleased]
 
+* Fix issue #548: Make time coordinate regularity check less stringent.
+
 ## [1.0.5] - 2025-03-06
 
 * Add technical support for python 3.10.
diff --git a/meridian/data/input_data.py b/meridian/data/input_data.py
index ff85d07..ad86cb4 100644
--- a/meridian/data/input_data.py
+++ b/meridian/data/input_data.py
@@ -542,11 +542,13 @@ class InputData:
     try:
       _ = self.time_coordinates.interval_days
     except ValueError as exc:
-      raise ValueError("Time coordinates must be evenly spaced.") from exc
+      raise ValueError("Time coordinates must be regularly spaced.") from exc
     try:
       _ = self.media_time_coordinates.interval_days
     except ValueError as exc:
-      raise ValueError("Media time coordinates must be evenly spaced.") from exc
+      raise ValueError(
+          "Media time coordinates must be regularly spaced."
+      ) from exc
 
   def _validate_time(self, array: xr.DataArray | None):
     """Validates the `time` dimension of the given `DataArray`.
diff --git a/meridian/data/time_coordinates.py b/meridian/data/time_coordinates.py
index b3a00f8..85a49fc 100644
--- a/meridian/data/time_coordinates.py
+++ b/meridian/data/time_coordinates.py
@@ -19,7 +19,6 @@ import dataclasses
 import datetime
 import functools
 from typing import TypeAlias
-import warnings
 
 from meridian import constants
 import numpy as np
@@ -145,6 +144,10 @@ class TimeCoordinates:
     return cls(datetime_index=_to_pandas_datetime_index(dates))
 
   def __post_init__(self):
+    if len(self.datetime_index) <= 1:
+      raise ValueError(
+          "There must be more than one date index in the time coordinates."
+      )
     if not self.datetime_index.is_monotonic_increasing:
       raise ValueError(
           "Time coordinates must be strictly monotonically increasing."
@@ -162,28 +165,46 @@ class TimeCoordinates:
 
   @functools.cached_property
   def interval_days(self) -> int:
-    """Returns the interval between two neighboring dates in `all_dates`.
+    """Returns the *mean* interval between two neighboring dates in `all_dates`.
 
     Raises:
-      ValueError if the date index is not regularly spaced.
+      ValueError if the date index is not "regularly spaced".
     """
-    # Calculate the difference between consecutive dates, in days.
-    diff = self.datetime_index.to_series().diff().dt.days.dropna()
+    if not self._is_regular_time_index():
+      raise ValueError("Time coordinates are not regularly spaced!")
 
-    if diff.nunique() == 0:
-      # This edge case happens when there is only one date in the index.
-      # This is unlikely to happen in practice, but we handle it just in case.
-      warnings.warn(
-          "The time coordinates only have one date. Returning an interval of 0."
-      )
-      return 0
+    # Calculate the difference between consecutive dates, in days.
+    diffs = self._interval_days
+    # Return the rounded mean interval.
+    return int(np.round(np.mean(diffs)))
 
-    # Check for regularity.
-    if diff.nunique() != 1:
-      raise ValueError("`datetime_index` coordinates are not evenly spaced!")
+  @property
+  def _timedelta_index(self) -> pd.TimedeltaIndex:
+    """Returns the timedeltas between consecutive dates in `datetime_index`."""
+    return self.datetime_index.diff().dropna()
 
-    # Finally, return the mode interval.
-    return diff.mode()[0]
+  @property
+  def _interval_days(self) -> Sequence[int]:
+    """Converts `_timedelta_index` to a sequence of days for easier compute."""
+    return self._timedelta_index.days.to_numpy()
+
+  def _is_regular_time_index(self) -> bool:
+    """Returns True if the time index is "regularly spaced"."""
+    if np.all(self._interval_days == self._interval_days[0]):
+      # All intervals are regular. Base case.
+      return True
+    # Special cases:
+    # * Monthly cadences
+    if np.all(np.isin(self._interval_days, [28, 29, 30, 31])):
+      return True
+    # * Quarterly cadences
+    if np.all(np.isin(self._interval_days, [90, 91, 92])):
+      return True
+    # * Yearly cadences
+    if np.all(np.isin(self._interval_days, [365, 366])):
+      return True
+
+    return False
 
   def get_selected_dates(
       self,


Test patch:
diff --git a/meridian/data/input_data_test.py b/meridian/data/input_data_test.py
index bb92a42..5149aac 100644
--- a/meridian/data/input_data_test.py
+++ b/meridian/data/input_data_test.py
@@ -956,15 +956,15 @@ class InputDataTest(parameterized.TestCase):
     # that it is not regularly spaced with other coordinate values.
     old_time_coords = kpi[constants.TIME].values
     new_time_coords = old_time_coords.copy()
-    new_time_coords[-1] = (
-        datetime.datetime.strptime(old_time_coords[-1], constants.DATE_FORMAT)
+    new_time_coords[-2] = (
+        datetime.datetime.strptime(old_time_coords[-2], constants.DATE_FORMAT)
         + datetime.timedelta(days=2)
     ).strftime(constants.DATE_FORMAT)
     kpi = kpi.assign_coords({constants.TIME: new_time_coords})
 
     with self.assertRaisesWithLiteralMatch(
         ValueError,
-        "Time coordinates must be evenly spaced.",
+        "Time coordinates must be regularly spaced.",
     ):
       input_data.InputData(
           controls=self.not_lagged_controls,
@@ -982,9 +982,9 @@ class InputDataTest(parameterized.TestCase):
     # value so that it is not regularly spaced with other coordinate values.
     old_media_time_coords = media[constants.MEDIA_TIME].values
     new_media_time_coords = old_media_time_coords.copy()
-    new_media_time_coords[-1] = (
+    new_media_time_coords[2] = (
         datetime.datetime.strptime(
-            old_media_time_coords[-1], constants.DATE_FORMAT
+            old_media_time_coords[2], constants.DATE_FORMAT
         )
         + datetime.timedelta(days=2)
     ).strftime(constants.DATE_FORMAT)
@@ -992,7 +992,7 @@ class InputDataTest(parameterized.TestCase):
 
     with self.assertRaisesWithLiteralMatch(
         ValueError,
-        "Media time coordinates must be evenly spaced.",
+        "Media time coordinates must be regularly spaced.",
     ):
       input_data.InputData(
           controls=self.not_lagged_controls,
diff --git a/meridian/data/time_coordinates_test.py b/meridian/data/time_coordinates_test.py
index 52492c6..189920c 100644
--- a/meridian/data/time_coordinates_test.py
+++ b/meridian/data/time_coordinates_test.py
@@ -53,6 +53,15 @@ class TimeCoordinatesTest(parameterized.TestCase):
         self.all_dates
     )
 
+  def test_constructor_must_have_more_than_one_date(self):
+    with self.assertRaisesRegex(
+        ValueError,
+        "There must be more than one date index",
+    ):
+      time_coordinates.TimeCoordinates.from_dates(
+          pd.DatetimeIndex([np.datetime64("2024-01-01")])
+      )
+
   def test_property_all_dates(self):
     expected_dates = [
         dt.datetime.strptime(date, constants.DATE_FORMAT).date()
@@ -113,22 +122,130 @@ class TimeCoordinatesTest(parameterized.TestCase):
     )
     self.assertEqual(coordinates.interval_days, 1)
 
-  def test_property_nonregular_interval_days(self):
+  @parameterized.named_parameters(
+      dict(
+          testcase_name="weekly_interval_days",
+          dates=[
+              dt.datetime(2024, 1, 1) + dt.timedelta(days=7 * i)
+              for i in range(14)
+          ],
+          expected_interval_days=7,
+      ),
+      dict(
+          testcase_name="thirty_day_interval_days",
+          dates=[
+              dt.datetime(2024, 1, 1) + dt.timedelta(days=30 * i)
+              for i in range(24)
+          ],
+          expected_interval_days=30,
+      ),
+  )
+  def test_property_interval_days_regular(
+      self, dates: list[dt.date], expected_interval_days
+  ):
+    coordinates = time_coordinates.TimeCoordinates.from_dates(
+        pd.DatetimeIndex(dates)
+    )
+    self.assertEqual(coordinates.interval_days, expected_interval_days)
+
+  @parameterized.named_parameters(
+      dict(
+          testcase_name="monthly_on_the_first_day_of_each_month_one_year",
+          dates=[dt.datetime(2024, m, 1) for m in range(1, 13)],
+          expected_interval_days=30,
+      ),
+      dict(
+          testcase_name="monthly_on_the_last_day_of_each_month_two_years",
+          dates=[
+              dt.datetime(2024 + y, m, 1) - dt.timedelta(days=1)  # pylint: disable=g-complex-comprehension
+              for y in range(2)
+              for m in range(1, 13)
+          ],
+          expected_interval_days=30,
+      ),
+      dict(
+          testcase_name="monthly_on_the_fifteenth_each_month_four_years",
+          dates=[
+              dt.datetime(2024 + y, m, 15)  # pylint: disable=g-complex-comprehension
+              for y in range(4)
+              for m in range(1, 13)
+          ],
+          expected_interval_days=30,
+      ),
+      dict(
+          testcase_name="quarterly_on_the_first_day_of_each_quarter_one_year",
+          dates=[dt.datetime(2024, q * 3 + 1, 1) for q in range(0, 4)],
+          expected_interval_days=91,
+      ),
+      dict(
+          testcase_name="yearly_with_leap_day",
+          dates=[dt.datetime(2023 + y, 1, 1) for y in range(5)],
+          expected_interval_days=365,
+      ),
+  )
+  def test_property_interval_days_fuzzy_regular(
+      self,
+      dates: list[dt.date],
+      expected_interval_days: int,
+  ):
+    coordinates = time_coordinates.TimeCoordinates.from_dates(
+        pd.DatetimeIndex(dates)
+    )
+    self.assertEqual(coordinates.interval_days, expected_interval_days)
+
+  @parameterized.named_parameters(
+      dict(
+          testcase_name="weekly_misses_one_entire_week",
+          dates=[
+              dt.datetime(2024, 1, 1)
+              + dt.timedelta(days=7 * (i + 1 if i >= 4 else i))
+              for i in range(20)
+          ],
+      ),
+      dict(
+          testcase_name="weekly_one_week_is_half_week",
+          dates=[
+              dt.datetime(2024, 1, 1)
+              + dt.timedelta(days=7 * i + (4 if i == 10 else 0))
+              for i in range(25)
+          ],
+      ),
+      dict(
+          testcase_name="monthly_one_month_is_off_by_ten_days",
+          dates=[
+              # 2024/3/10 on March, 15th for the rest.
+              dt.date(2024, m, 15) - dt.timedelta(days=(10 if m == 2 else 0))
+              for m in range(1, 13)
+          ],
+      ),
+  )
+  def test_property_interval_days_fuzzy_irregular(
+      self,
+      dates: list[dt.date],
+  ):
+    coordinates = time_coordinates.TimeCoordinates.from_dates(
+        pd.DatetimeIndex(dates)
+    )
+    with self.assertRaisesRegex(
+        ValueError,
+        "Time coordinates are not regularly spaced!",
+    ):
+      _ = coordinates.interval_days
+
+  def test_property_irregular_interval_days(self):
+    dates = ["2024-01-01", "2024-01-03", "2024-01-10"]
     all_dates = xr.DataArray(
-        data=np.array(["2024-01-01", "2024-01-08", "2024-01-16"]),
+        data=np.array(dates),
         dims=[constants.TIME],
         coords={
-            constants.TIME: (
-                [constants.TIME],
-                ["2024-01-01", "2024-01-08", "2024-01-16"],
-            ),
+            constants.TIME: ([constants.TIME], dates),
         },
     )
     coordinates = time_coordinates.TimeCoordinates.from_dates(all_dates)
 
     with self.assertRaisesRegex(
         ValueError,
-        "`datetime_index` coordinates are not evenly spaced!",
+        "Time coordinates are not regularly spaced!",
     ):
       _ = coordinates.interval_days