You can do something like
bin_edges = [1,3,5,7,9,11]
bins = pd.cut(df.x, bin_edges, right=False)
df_new = pd.DataFrame({"LB": bin_edges[:-1], "RB": bin_edges[1:]})
binned = df.groupby(bins.values.codes)["y"]
df_new["N"] = binned.count()
df_new["N"] = df_new["N"].fillna(0)
df_new["Pcnt1"] = binned.mean()
which gives
>>> df_new
LB RB N Pcnt1
0 1 3 2 0.500000
1 3 5 1 1.000000
2 5 7 0 NaN
3 7 9 3 0.666667
4 9 11 1 1.000000
(This uses the exclusive RB agreement.)
Here all the hard work is done pd.cut, which returns a series of dtype categories:
>>> bins
0 [1, 3)
1 [1, 3)
2 [3, 5)
3 [7, 9)
4 [7, 9)
5 [7, 9)
6 [9, 11)
Name: x, dtype: category
Categories (5, object): [[1, 3) < [3, 5) < [5, 7) < [7, 9) < [9, 11)]
Since we want to align, I went down to the basic bin indices:
>>> bins.values.codes
array([0, 0, 1, 3, 3, 3, 4], dtype=int8)
, , , 100, NaN -1, () , df_new.