code: def _learn_structure(
      X: np.ndarray,
      bnds,
      max_iter: int = 100,
      h_tol: float = 1e-8,
      w_threshold: float = 0.0,
  ) -> StructureModel:
      """
      Based on initial implementation at https://github.com/xunzheng/notears
      """
  
      def _h(w: np.ndarray) -> float:
          """
          Constraint function of the NOTEARS algorithm.
  
          Args:
              w:  current adjacency matrix.
  
          Returns:
              float: DAGness of the adjacency matrix (0 == DAG, >0 == cyclic).
          """
  
          W = w.reshape([d, d])
          return np.trace(slin.expm(W * W)) - d
  
      def _func(w: np.ndarray) -> float:
          """
          Objective function that the NOTEARS algorithm tries to minimise.
  
          Args:
              w: current adjacency matrix.
  
          Returns:
              float: objective.
          """
  
          W = w.reshape([d, d])
          loss = (
              0.5
              / n
              * np.square(
                  np.linalg.norm(X.dot(np.eye(d, d) - W), "fro")
              )
          )
          h = _h(W)
          return loss + 0.5 * rho * h * h + alpha * h
  
      def _grad(w: np.ndarray) -> np.ndarray:
          """
          Gradient function used to compute next step in NOTEARS algorithm.
  
          Args:
              w: the current adjacency matrix.
  
          Returns:
              np.ndarray: gradient vector.
          """
  
          W = w.reshape([d, d])
          loss_grad = -1.0 / n * X.T.dot(X).dot(np.eye(d, d) - W)
          E = slin.expm(W * W)
          obj_grad = (
              loss_grad
              + (rho * (np.trace(E) - d) + alpha) * E.T * W * 2
          )
          return obj_grad.flatten()
  
      if X.size == 0:
          raise ValueError(
              "Input data X is empty, cannot learn any structure"
          )
      logging.info("Learning structure using 'NOTEARS' optimisation.")
  
      # n examples, d properties
      n, d = X.shape
      # initialise matrix to zeros
      w_est, w_new = np.zeros(d * d), np.zeros(d * d)
  
      # initialise weights and constraints
      rho, alpha, h, h_new = 1.0, 0.0, np.inf, np.inf
  
      # start optimisation
      for n_iter in range(max_iter):
          while (rho < 1e20) and (h_new > 0.25 * h or h_new == np.inf):
              sol = sopt.minimize(
                  _func,
                  w_est,
                  method="L-BFGS-B",
                  jac=_grad,
                  bounds=bnds,
              )
              w_new = sol.x
              h_new = _h(w_new)
              if h_new > 0.25 * h:
                  rho *= 10
  
          w_est, h = w_new, h_new
          alpha += rho * h
          if h <= h_tol:
              break
          if h > h_tol and n_iter == max_iter - 1:
              warnings.warn(
                  "Failed to converge. Consider increasing max_iter."
              )
  
      w_est[np.abs(w_est) <= w_threshold] = 0
      return StructureModel(w_est.reshape([d, d]))
  
file_path: causalnex/causalnex/structure/notears.py
imports: import logging
  import warnings
  from copy import deepcopy
  from typing import List, Tuple
  import numpy
  import pandas
  import scipy.linalg
  import scipy.optimize
  from causalnex.structure.structuremodel import StructureModel
label_desc: Use image node_id 5 for calling a global function with example usage: _learn_structure(X, bnds, max_iter, h_tol, w_threshold) and returns: StructureModel, unknown, unknown, obj_grad