482 lines
21 KiB
C++
482 lines
21 KiB
C++
/* Copyright 2022 The TensorFlow Authors. All Rights Reserved.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
==============================================================================*/
|
|
|
|
#include <algorithm>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <tuple>
|
|
#include <vector>
|
|
|
|
#include "absl/algorithm/container.h"
|
|
#include "absl/container/btree_set.h"
|
|
#include "absl/container/flat_hash_set.h"
|
|
#include "absl/status/status.h"
|
|
#include "absl/strings/match.h"
|
|
#include "absl/strings/str_cat.h"
|
|
#include "absl/strings/str_split.h"
|
|
#include "absl/strings/string_view.h"
|
|
#include "absl/types/optional.h"
|
|
#include "tensorflow/cc/framework/grad_op_registry.h"
|
|
#include "tensorflow/cc/framework/gradients.h"
|
|
#include "tensorflow/cc/gradients/grad_helper.h"
|
|
#include "tensorflow/cc/ops/array_ops_internal.h"
|
|
#include "tensorflow/cc/ops/math_ops_internal.h"
|
|
#include "tensorflow/cc/ops/standard_ops.h"
|
|
#include "tensorflow/core/framework/types.pb.h"
|
|
|
|
namespace tensorflow {
|
|
namespace ops {
|
|
namespace {
|
|
|
|
constexpr absl::string_view kEllipsis = "...";
|
|
|
|
// Returns the axis (possibly negative) corresponding to a label.
|
|
//
|
|
// Returns the axis index of the axis label if it is before an ellipsis (or if
|
|
// the ellipsis is not present), and the negative index if it occurs after the
|
|
// ellipsis. E.g. index of `b` in `ab...cd`, is `1`, but that of `c` is `-2`.
|
|
//
|
|
// For multiple occurrences, returns the leftmost one. If not found, returns
|
|
// absl::nullopt.
|
|
//
|
|
// Parameters:
|
|
// subscripts: A string denoting the einsum subscript (e.g. `ab...cd`)
|
|
// label: The single character axis label.
|
|
std::optional<int> EinsumGetAxisFromLabel(absl::string_view subscripts,
|
|
char label) {
|
|
std::vector<absl::string_view> splits = absl::StrSplit(subscripts, kEllipsis);
|
|
auto index = splits[0].find(label);
|
|
if (index != splits[0].npos) {
|
|
return index;
|
|
}
|
|
if (splits.size() < 2) {
|
|
return std::nullopt;
|
|
}
|
|
index = splits[1].find(label);
|
|
if (index != splits[1].npos) {
|
|
return index - splits[1].length();
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
|
|
// Returns a tuple denoting the slice mapping to ellipsis.
|
|
//
|
|
// For a given subscript, returns a tuple (start, end) denoting the start
|
|
// axis index and the (negative) end axis index respectively. For any input
|
|
// Tensor `x` described by the subscript, `x[start:end]` would be the slice
|
|
// represented by the ellipsis. E.g. For `ab...cd` returns `[1, -2]`.
|
|
//
|
|
// If ellipsis is not present in `subscripts`, returns `(0, 0)`.
|
|
//
|
|
// Parameters:
|
|
// subscripts: A string denoting the einsum subscript.
|
|
// start: Output for the start index
|
|
// end: Output for the end index (or nullopt to go to the end).
|
|
std::tuple<int, std::optional<int>> EinsumGetBcastSubshape(
|
|
absl::string_view subscripts) {
|
|
int start = subscripts.find(kEllipsis);
|
|
if (start == subscripts.npos) {
|
|
return std::make_tuple(0, 0);
|
|
}
|
|
int remaining = subscripts.length() - (start + kEllipsis.length());
|
|
std::optional<int> end;
|
|
if (remaining > 0) {
|
|
end = -remaining;
|
|
} else {
|
|
end = std::nullopt;
|
|
}
|
|
return std::make_tuple(start, end);
|
|
}
|
|
|
|
// Slices elements of a 1d tensor from [start,end].
|
|
// If end is nullopt, it goes to the end of the tensor.
|
|
// Supports negative values for end.
|
|
// This attempts to give the same result as tenspr[start:end] would give in
|
|
// Python.
|
|
Output Slice1dHelper(const Scope& scope, Output tensor, int start,
|
|
std::optional<int> end) {
|
|
if (end.has_value() && *end > 0) {
|
|
return Slice(scope, tensor, Const(scope, start, TensorShape({1})),
|
|
Const(scope, *end - start, TensorShape({1})));
|
|
} else {
|
|
return Slice(scope, tensor, Const(scope, start, TensorShape({1})),
|
|
Add(scope, Shape(scope, tensor), end.value_or(0) - start));
|
|
}
|
|
}
|
|
|
|
// Returns reduced subscripts and their corresponding dimensions and axes.
|
|
//
|
|
// Given a set of axis labels, returns their concatenated subscript, their
|
|
// corresponding dimensions from input_shape, and their corresponding axes.
|
|
// Note that the concatenated subscript `reduced_subs` may have axis labels
|
|
// from `reduced_label_set` in any order. For example, for the reduced label
|
|
// set `{b, d}`, subscripts `aabbcd` and input shape `[2,2,5,5,3,4]`, returns
|
|
// subscripts `bd`, dimensions `[5,4]` and axes `[2,5]`.
|
|
//
|
|
// Args:
|
|
// reduced_label_set: Set of axis labels which appear in `subscripts`.
|
|
// input_shape: A `Tensor` representing the shape of the einsum operand
|
|
// corresponding to `subscripts`.
|
|
// subscripts: A string denoting the einsum subscript.
|
|
//
|
|
// Returns:
|
|
// reduced_subs: Subscripts formed by a concatenation of labels in
|
|
// `reduced_label_set`.
|
|
// reduced_dims: Dimensions from `input_shape` corresponding to each label
|
|
// in `reduced_subs`.
|
|
// reduced_axes: Axes described by `subscripts` corresponding to each label
|
|
// in `reduced_subs`. If there are multiple occurrences in `subscripts`,
|
|
// we consider only the leftmost one.
|
|
std::tuple<std::string, Output, Output> EinsumGetReducedSubscripts(
|
|
const Scope& scope, const absl::btree_set<char>& reduced_label_set,
|
|
Output input_shape, absl::string_view subscripts) {
|
|
// Concatenate the sequence of reduced axis labels.
|
|
const std::string reduced_subs =
|
|
std::string(reduced_label_set.begin(), reduced_label_set.end());
|
|
// Get the axis (may be positive, negative or zero) for each of the reduced
|
|
// labels. If the same label appears multiple times, get the left-most axis.
|
|
std::vector<int> reduced_axes;
|
|
reduced_axes.reserve(reduced_subs.size());
|
|
for (const char s : reduced_subs) {
|
|
auto axis = EinsumGetAxisFromLabel(subscripts, s);
|
|
if (!axis.has_value()) {
|
|
// Should never happen.
|
|
scope.UpdateStatus(absl::InternalError(
|
|
absl::StrCat("Missing axis", absl::string_view(&s, 1))));
|
|
} else {
|
|
reduced_axes.push_back(*axis);
|
|
}
|
|
}
|
|
// Get the corresponding dimensions for each reduced axis.
|
|
std::vector<Output> reduced_dims_inputs;
|
|
reduced_dims_inputs.reserve(reduced_axes.size());
|
|
for (const int i : reduced_axes) {
|
|
if (i < 0) {
|
|
reduced_dims_inputs.push_back(
|
|
Gather(scope, input_shape, Add(scope, Size(scope, input_shape), i)));
|
|
} else {
|
|
reduced_dims_inputs.push_back(Gather(scope, input_shape, i));
|
|
}
|
|
}
|
|
const Output reduced_dims = Stack(scope, reduced_dims_inputs);
|
|
Tensor reduced_axes_tensor(
|
|
DataType::DT_INT32, TensorShape({static_cast<int>(reduced_axes.size())}));
|
|
std::copy_n(reduced_axes.begin(), reduced_axes.size(),
|
|
reduced_axes_tensor.flat<int>().data());
|
|
return std::make_tuple(reduced_subs, reduced_dims,
|
|
Const(scope, reduced_axes_tensor));
|
|
}
|
|
|
|
// Returns the gradient wrt input for a unary einsum with reductions.
|
|
//
|
|
// scope: Scope for grad operations.
|
|
// output_grad: The gradient wrt the output of a unary einsum operation.
|
|
// output_subs: The output subscript. (E.g. `ac` for equation `abc->ac`).
|
|
// input_subs: The input subscript. (E.g. `abc` for equation `abc->ac`).
|
|
// input_shape: The shape of the input operand.
|
|
// reduced_label_set: The set of axis labels appearing in `input_subs` but
|
|
// not in `output_subs`.
|
|
Output EinsumGradReducedHelper(const Scope& scope, const Output& output_grad,
|
|
absl::string_view output_subs,
|
|
absl::string_view input_subs,
|
|
const Output& input_shape,
|
|
const absl::btree_set<char>& reduced_label_set) {
|
|
// Let's say the einsum operation was "aabbcd->ca", where axis labels 'b' and
|
|
// 'd' are reduced with input_shape [2,2,5,5,3,4]. Then obtain the reduced
|
|
// subscripts "bd", corresponding dimensions [5,4] and axes [2,5].
|
|
std::string reduced_subs;
|
|
Output reduced_dims, reduced_axes;
|
|
std::tie(reduced_subs, reduced_dims, reduced_axes) =
|
|
EinsumGetReducedSubscripts(scope, reduced_label_set, input_shape,
|
|
input_subs);
|
|
// Whether either the input or the output subscripts have a repeated label.
|
|
// This is true for "aabbcd->ca" or "abd->cca" but false for "abcd->ca".
|
|
const int distinct_input_labels =
|
|
absl::flat_hash_set<char>(input_subs.begin(), input_subs.end()).size();
|
|
const int distinct_output_labels =
|
|
absl::flat_hash_set<char>(output_subs.begin(), output_subs.end()).size();
|
|
const bool has_repeated_labels =
|
|
(distinct_input_labels + distinct_output_labels) <
|
|
input_subs.length() + output_subs.length();
|
|
// Compute the input subscripts without the reduced axis labels, e.g. "aac"
|
|
// for the equation "aabbcd->ca".
|
|
std::string input_subs_without_reduced_labels;
|
|
for (const char s : input_subs) {
|
|
if (!absl::c_linear_search(reduced_label_set, s)) {
|
|
input_subs_without_reduced_labels.push_back(s);
|
|
}
|
|
}
|
|
|
|
// The gradient wrt the input for the equation "abc->ac" (or, equivalently
|
|
// reduce_sum(..., axis=1)) is just the gradient of the output tiled N times
|
|
// along axis 1, where label 'b' represents a dimension of size N.
|
|
//
|
|
// If we're not dealing with repeated labels, and the non-reduced labels
|
|
// doesn't need to be transposed, then just tiling is enough and there is no
|
|
// need to call another einsum. For example, tiling is sufficient for
|
|
// "abcd->ac". But for equations like "aabbcd->ac" (generalized traces) or
|
|
// "abc->ca" (transpose), we'd need another einsum operation after tiling.
|
|
if (!has_repeated_labels &&
|
|
input_subs_without_reduced_labels == output_subs) {
|
|
// Obtain the shape of the output, as if keepdims=True on reduce sum. E.g.
|
|
// for the equation "abcd->ac" with input shape [2,5,3,4], we get the
|
|
// reduced shape [2,1,3,1].
|
|
auto reduced_shape = ReducedShapeHelper(scope, input_shape, reduced_axes);
|
|
// Reshaping the gradient (wrt "ac") to [2,1,3,1] and broadcasting it to
|
|
// the shape [2,5,3,4] results in the gradient wrt "abcd".
|
|
return BroadcastTo(scope, Reshape(scope, output_grad, reduced_shape),
|
|
input_shape);
|
|
}
|
|
|
|
// If we *do* have traces or transpose operations, then prepend the extra
|
|
// reduced dimensions to the front. E.g. Given the equation "aabbcd->ca" we'd
|
|
// first obtain the VJP for "bdca->ca", and then the VJP for "aabbcd->bdca".
|
|
//
|
|
// Obtain the input shape with reduced dimensions prepended, viz. [5,4,3,2].
|
|
// This is the shape of the intermediate "bdca".
|
|
Output output_grad_shape = Shape(scope, output_grad);
|
|
auto grad_shape_with_reduced_labels =
|
|
Concat(scope, {reduced_dims, output_grad_shape}, /*axis=*/0);
|
|
|
|
// Obtain the output shape of the reduction-only equation "bdca->ca" as if
|
|
// keepdims=True; viz. [1,1,3,2]. Since we prepended the reduced labels,
|
|
// we just have to prepend that many 1s to the output shape.
|
|
|
|
auto reduced_shape = Concat(
|
|
scope,
|
|
{Const(scope, 1, TensorShape{static_cast<int>(reduced_label_set.size())}),
|
|
output_grad_shape},
|
|
/*axis=*/0);
|
|
// Compute the VJP for the intermediate (viz. "bdca->ca") for which
|
|
// broadcasting is sufficient.
|
|
Output broadcasted_grad =
|
|
BroadcastTo(scope, Reshape(scope, output_grad, reduced_shape),
|
|
grad_shape_with_reduced_labels);
|
|
// Compute the VJP for the final step (viz. "aabbcd->bdca"). We can
|
|
// use einsum with the input and output subscripts reversed (viz.
|
|
// "bdca->aabbcd") since the output axis labels now appear in the
|
|
// input subscripts.
|
|
return Einsum(scope, {broadcasted_grad},
|
|
absl::StrCat(reduced_subs, output_subs, "->", input_subs));
|
|
}
|
|
|
|
// Returns the gradient wrt an input operand for a binary einsum.
|
|
//
|
|
// This function does not handle (un)broadcasting. This must be done separately
|
|
// on the returned gradient.
|
|
//
|
|
// Args:
|
|
// output_grad: The gradient wrt the output of a binary einsum operation.
|
|
// other_operand: The complementary `Tensor` operand i.e. which is not the
|
|
// input operand.
|
|
// input_shape: A `Tensor` representing the shape of input operand.
|
|
// input_subs: The subscripts of the input operand.
|
|
// other_subs: The subscripts of the complementary operand.
|
|
// output_subs: The output subscripts.
|
|
Output EinsumGradWrt(const Scope& scope, Output output_grad,
|
|
Output other_operand, Output input_shape,
|
|
absl::string_view input_subs, absl::string_view other_subs,
|
|
absl::string_view output_subs) {
|
|
// Claim: For the einsum operation z = einsum("{eq_x},{eq_y}->{eq_z}", x, y),
|
|
// where the equation involves only Tensor contractions, generalized traces
|
|
// and transposes, the input gradients are given by the vector-jacobian
|
|
// products (VJPs):
|
|
//
|
|
// grad_wrt_x = einsum("{eq_y},{eq_z}->{eq_x}", y, grad_wrt_z)
|
|
// grad_wrt_y = einsum("{eq_x},{eq_z}->{eq_y}", x, grad_wrt_z}
|
|
//
|
|
// where grad_wrt_x and grad_wrt_y are the gradients with respect to inputs
|
|
// x and y and grad_wrt_z is the given gradient with respect to output z.
|
|
//
|
|
// Proof: For unary einsum equations involving only transpose ("ij->ji") and
|
|
// traces ("ii->i"), the linear mapping's Jacobian at input x is given
|
|
// by the function itself. We can verify that the linear map given by the
|
|
// VJP are einsums with the equations "ji->ij" and "i->ii" respectively,
|
|
// where the latter represents 'un-tracing', or filling the diagonal with
|
|
// the input axis and non-diagonal entries are zeros.
|
|
// Furthermore, recall that matrix multiplication, which is
|
|
// represented by the equation "ab,bc->ac", has its VJPs given by the
|
|
// einsum equations "ac,bc->ab" and "ab,ac->bc" (see, for example
|
|
// https://math.stackexchange.com/a/2755680). Combined with transposes and
|
|
// traces we can rewrite Tensor contractions as regular matrix
|
|
// multiplication. Since each of these operations have their VJPs described
|
|
// by einsums of the required pattern, the result follows.
|
|
//
|
|
// Accordingly, einsum operations except for those with reductions, e.g.
|
|
// "abc,cd->ad" have their VJPs defined by:
|
|
// "{output_subs},{other_subs}->{input_subs}".
|
|
//
|
|
// But if there is a reduction, this would lead to the equation "ad,cd->abc"
|
|
// which is invalid because the reduced axis label 'b' is present in the
|
|
// output but not in any of the inputs. Therefore, we compute the VJP in two
|
|
// steps: first we obtain VJP for "ac,cd->ad" and then we compute the VJP of
|
|
// "abc->ac" or, equivalently, reduce_sum(..., axis=1).
|
|
//
|
|
// Compute the set of input axis labels which doesn't appear in either the
|
|
// output subscripts or the other operand's subscript. E.g. the set {'b'} for
|
|
// the equation "abc,cd->ad".
|
|
absl::btree_set<char> reduced_label_set(input_subs.begin(), input_subs.end());
|
|
for (const char x : output_subs) {
|
|
reduced_label_set.erase(x);
|
|
}
|
|
for (const char x : other_subs) {
|
|
reduced_label_set.erase(x);
|
|
}
|
|
reduced_label_set.erase('.');
|
|
|
|
// Obtain the input subscripts with the reduced axis labels removed. E.g.
|
|
// "ac" in the above example.
|
|
std::string left_subs;
|
|
for (const char s : input_subs) {
|
|
if (!reduced_label_set.contains(s)) {
|
|
left_subs.push_back(s);
|
|
}
|
|
}
|
|
|
|
// Compute the gradient wrt the input, without accounting for the operation
|
|
// "abc->ac". So, now we have the VJP of the operation "ac,cd->ad".
|
|
Output grad_reduced =
|
|
Einsum(scope, {output_grad, other_operand},
|
|
absl::StrCat(output_subs, ",", other_subs, "->", left_subs));
|
|
|
|
// If the reduced_label_set is empty, then we already have the gradient
|
|
// wrt the input.
|
|
if (reduced_label_set.empty()) {
|
|
return grad_reduced;
|
|
}
|
|
// Otherwise, we currently have the gradient wrt the output of the reduction
|
|
// operation "abc->ac". Invoke the subroutine for the gradient for unary
|
|
// einsum with reductions.
|
|
return EinsumGradReducedHelper(scope, grad_reduced, left_subs, input_subs,
|
|
input_shape, reduced_label_set);
|
|
}
|
|
|
|
absl::Status EinsumGrad(const Scope& scope, const Operation& op,
|
|
const std::vector<Output>& grad_inputs,
|
|
std::vector<Output>* grad_outputs) {
|
|
if (grad_inputs.size() != 1) {
|
|
return absl::InvalidArgumentError("Expect 1 grad input.");
|
|
}
|
|
const Output& grad = grad_inputs[0];
|
|
|
|
std::string equation;
|
|
TF_RETURN_IF_ERROR(GetNodeAttr(op.node()->attrs(), "equation", &equation));
|
|
std::vector<absl::string_view> equation_split =
|
|
absl::StrSplit(equation, "->");
|
|
if (equation_split.size() != 2) {
|
|
return absl::InvalidArgumentError("Equation must contain a single ->");
|
|
}
|
|
|
|
const absl::string_view input_subs = equation_split[0];
|
|
const absl::string_view output_subs = equation_split[1];
|
|
if (op.num_inputs() == 1) {
|
|
// For the unary einsum z = einsum("{eq_x}->{eq_z}", x), the gradient wrt
|
|
// the input (VJP) is given by the reversed equation:
|
|
// grad_wrt_x = einsum("{eq_z}->{eq_x}", grad_wrt_z)
|
|
// (See the justification in _GetGradWrt). This is valid unless there are
|
|
// reduced axis labels; i.e. axis labels appearing in the input but not in
|
|
// the output subscripts.
|
|
auto input_shape = Shape(scope, op.input(0));
|
|
// Find the axis labels which appear only in the input.
|
|
absl::btree_set<char> reduced_label_set(input_subs.begin(),
|
|
input_subs.end());
|
|
for (const char x : output_subs) {
|
|
reduced_label_set.erase(x);
|
|
}
|
|
reduced_label_set.erase('.');
|
|
if (reduced_label_set.empty()) {
|
|
grad_outputs->push_back(Einsum(
|
|
scope, grad_inputs, absl::StrCat(output_subs, "->", input_subs)));
|
|
return scope.status();
|
|
}
|
|
// We do have reduced axes, so we invoke the subroutine for reduced unary
|
|
// einsums.
|
|
grad_outputs->push_back(EinsumGradReducedHelper(
|
|
scope, grad, output_subs, input_subs, input_shape, reduced_label_set));
|
|
return scope.status();
|
|
}
|
|
|
|
std::vector<absl::string_view> subs = absl::StrSplit(input_subs, ',');
|
|
if (subs.size() != 2) {
|
|
return absl::InvalidArgumentError("Only 2 inputs are supported");
|
|
}
|
|
std::string x_subs(subs[0]);
|
|
std::string y_subs(subs[1]);
|
|
// Add ellipsis for broadcasted dimensions if any operand does not have it.
|
|
// This is because the equation "...ij,jk->ik" may be valid if the 0th input's
|
|
// batch shape is empty, but the VJP equation "jk,ik->...ij" is not valid
|
|
// because only the output subscripts contain ellipsis.
|
|
if (absl::StrContains(output_subs, kEllipsis)) {
|
|
if (!absl::StrContains(x_subs, kEllipsis)) {
|
|
absl::StrAppend(&x_subs, kEllipsis);
|
|
}
|
|
if (!absl::StrContains(y_subs, kEllipsis)) {
|
|
absl::StrAppend(&y_subs, kEllipsis);
|
|
}
|
|
}
|
|
|
|
// Obtain the gradients wrt the inputs x and y, without taking into account
|
|
// the unbroadcasting.
|
|
tensorflow::Output x = op.input(0);
|
|
tensorflow::Output y = op.input(1);
|
|
if (DataTypeIsComplex(grad.type())) {
|
|
x = Conj(scope, x);
|
|
y = Conj(scope, y);
|
|
}
|
|
|
|
const auto x_shape = Shape(scope, x);
|
|
const auto y_shape = Shape(scope, y);
|
|
Output grad_x =
|
|
EinsumGradWrt(scope, grad, y, x_shape, x_subs, y_subs, output_subs);
|
|
Output grad_y =
|
|
EinsumGradWrt(scope, grad, x, y_shape, y_subs, x_subs, output_subs);
|
|
|
|
if (!absl::StrContains(output_subs, kEllipsis)) {
|
|
// If no ellipsis in the output; then no need to unbroadcast.
|
|
grad_outputs->push_back(grad_x);
|
|
grad_outputs->push_back(grad_y);
|
|
return scope.status();
|
|
}
|
|
|
|
// Below we handle the case that broadcasting between x and y was necessary,
|
|
// with x and y having possibly different batch shapes.
|
|
|
|
// Obtain the range of axes which map to ellipsis. E.g. for subscripts
|
|
// 'ab...c' and shape of rank 10; the range [3:-1] denotes the broadcasted
|
|
// axes.
|
|
int bx_start, by_start;
|
|
std::optional<int> bx_end, by_end;
|
|
std::tie(bx_start, bx_end) = EinsumGetBcastSubshape(x_subs);
|
|
std::tie(by_start, by_end) = EinsumGetBcastSubshape(y_subs);
|
|
|
|
// Sum the gradient across the broadcasted axes.
|
|
auto args = internal::BroadcastGradientArgs(
|
|
scope, Slice1dHelper(scope, x_shape, bx_start, bx_end),
|
|
Slice1dHelper(scope, y_shape, by_start, by_end));
|
|
grad_x = Reshape(
|
|
scope, ReduceSum(scope, grad_x, Add(scope, bx_start, args.r0)), x_shape);
|
|
grad_y = Reshape(
|
|
scope, ReduceSum(scope, grad_y, Add(scope, by_start, args.r1)), y_shape);
|
|
grad_outputs->push_back(grad_x);
|
|
grad_outputs->push_back(grad_y);
|
|
return scope.status();
|
|
}
|
|
|
|
REGISTER_GRADIENT_OP("Einsum", EinsumGrad);
|
|
|
|
} // namespace
|
|
} // namespace ops
|
|
} // namespace tensorflow
|