//  [python]
def estimate_Sigma_x_inv(
		Xs, Sigma_x_inv, Es, use_spatial, lambda_Sigma_x_inv, power_Sigma_x_inv, betas,
		optimizer, context, n_epochs=10000,
		batch_size=-1, valid_freq=50,
):
	if not any(sum(map(len, E)) > 0 and u for E, u in zip(Es, use_spatial)):
		return None, np.nan
	if batch_size == -1:
		batch_size = sum(map(len, Xs))
	use_minibatch = batch_size < sum(map(len, Xs))
	linear_term = torch.zeros_like(Sigma_x_inv).requires_grad_(False)
	nu_list = []
	weight_list = []
	num_edges = 0
	for X, E, u, beta in zip(Xs, Es, use_spatial, betas):
		if not u:
			continue
		X = X.to(**context)
		Z = X / torch.linalg.norm(X, axis=1, ord=1, keepdim=True)
		E = [(i, j) for i, e in enumerate(E) for j in e]
		num_edges += beta * sum(map(len, E))
		E = torch.sparse_coo_tensor(np.array(E).T, np.ones(len(E)), size=[len(Z)]*2, **context)
		nu = E @ Z
		linear_term.addmm_(Z.T, nu, alpha=beta)
		nu_list.append(nu)
		weight_list.append(np.full(len(nu), beta))
	# linear_term = (linear_term + linear_term.T) / 2 # should be unnecessary as long as E is symmetric

	nu_all = torch.cat(nu_list, dim=0)
	weight_all = torch.tensor(np.concatenate(weight_list), **context)
	mini_batcher = Minibatcher((nu_all, weight_all), batch_size=batch_size)

	history = []
	Sigma_x_inv.requires_grad_(True)
	loss_prev, loss = np.inf, np.nan
	pbar = trange(1, n_epochs+1, desc='Updating Σx-1', miniters=valid_freq)
	Sigma_x_inv_best, loss_best, i_epoch_best = None, np.inf, -1
	Sigma_x_inv_prev = Sigma_x_inv.clone().detach_()

	def calc_func(mode):
		loss = Sigma_x_inv.view(-1) @ linear_term.view(-1)
		nu, weight = mini_batcher.sample() if mode == 'train' else (nu_all, weight_all)
		eta = nu @ Sigma_x_inv
		logZ = integrate_of_exponential_over_simplex(eta)
		# loss = loss + beta * len(nu_all) * logZ.mean()
		loss = loss + len(nu_all) / len(nu) * logZ @ weight
		loss = loss / num_edges
		loss = loss + lambda_Sigma_x_inv * Sigma_x_inv.abs().pow(power_Sigma_x_inv).sum()
		return loss

	for i_epoch in pbar:
		optimizer.zero_grad()
		loss = calc_func('train')
		loss.backward()
		Sigma_x_inv.grad = (Sigma_x_inv.grad + Sigma_x_inv.grad.T) / 2
		optimizer.step()
		optimizer.zero_grad()

		with torch.no_grad():
			Sigma_x_inv -= Sigma_x_inv.mean()

		with torch.no_grad():
			if i_epoch % valid_freq == 0:
				if use_minibatch:
					loss = calc_func('eval')
				loss = loss.item()
				dloss = loss_prev - loss
				loss_prev = loss

				history.append({
					'epoch': i_epoch,
					'param': Sigma_x_inv.clone().detach_(),
					'loss': loss * num_edges,
				})
				if history[-1]['loss'] < loss_best:
					Sigma_x_inv_best = history[-1]['param']
					loss_best = history[-1]['loss']
					i_epoch_best = i_epoch
				dSigma_x_inv = Sigma_x_inv_prev.sub_(Sigma_x_inv).abs_().max().item()
				Sigma_x_inv_prev[:] = Sigma_x_inv
				pbar.set_description(
					f'Updating Σx-1: loss = {dloss:.1e} -> {loss:.1e} '
					f'δΣx-1 = {dSigma_x_inv:.1e} '
					f'Σx-1 range = {Sigma_x_inv.min().item():.1e} ~ {Sigma_x_inv.max().item():.1e}'
				)
				if dSigma_x_inv < 1e-1 or i_epoch - i_epoch_best >= 2 * valid_freq:
					break

	with torch.no_grad():
		Sigma_x_inv[:] = Sigma_x_inv_best
	Sigma_x_inv.requires_grad_(False)

	history = pd.DataFrame(history)
	return history, -history['loss'].min()